This commit is contained in:
edison 2025-06-26 03:45:45 +00:00 committed by GitHub
commit 65ef847f25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
139 changed files with 13631 additions and 962 deletions

View File

@ -0,0 +1,62 @@
import path from 'node:path'
import {
E2E_TIMEOUT,
setupPuppeteer,
} from '../../../packages/vue/__tests__/e2e/e2eUtils'
import connect from 'connect'
import sirv from 'sirv'
import { nextTick } from 'vue'
import { ports } from '../utils'
const { page, click, html } = setupPuppeteer()
describe('vapor teleport', () => {
let server: any
const port = ports.teleport
beforeAll(() => {
server = connect()
.use(sirv(path.resolve(import.meta.dirname, '../dist')))
.listen(port)
process.on('SIGTERM', () => server && server.close())
})
afterAll(() => {
server.close()
})
beforeEach(async () => {
const baseUrl = `http://localhost:${port}/teleport/`
await page().goto(baseUrl)
await page().waitForSelector('#app')
})
test(
'render vdom component',
async () => {
const targetSelector = '.target'
const testSelector = '.interop-render-vdom-comp'
const containerSelector = `${testSelector} > div`
const btnSelector = `${testSelector} > button`
const tt = await html('#app')
console.log(tt)
// teleport is disabled
expect(await html(containerSelector)).toBe('<h1>vdom comp</h1>')
expect(await html(targetSelector)).toBe('')
// enable teleport
await click(btnSelector)
await nextTick()
expect(await html(containerSelector)).toBe('')
expect(await html(targetSelector)).toBe('<h1>vdom comp</h1>')
// disable teleport
await click(btnSelector)
await nextTick()
expect(await html(containerSelector)).toBe('<h1>vdom comp</h1>')
expect(await html(targetSelector)).toBe('')
},
E2E_TIMEOUT,
)
})

View File

@ -5,6 +5,7 @@ import {
} from '../../../packages/vue/__tests__/e2e/e2eUtils' } from '../../../packages/vue/__tests__/e2e/e2eUtils'
import connect from 'connect' import connect from 'connect'
import sirv from 'sirv' import sirv from 'sirv'
import { ports } from '../utils'
describe('e2e: todomvc', () => { describe('e2e: todomvc', () => {
const { const {
@ -23,7 +24,7 @@ describe('e2e: todomvc', () => {
} = setupPuppeteer() } = setupPuppeteer()
let server: any let server: any
const port = '8194' const port = ports.todomvc
beforeAll(() => { beforeAll(() => {
server = connect() server = connect()
.use(sirv(path.resolve(import.meta.dirname, '../dist'))) .use(sirv(path.resolve(import.meta.dirname, '../dist')))

View File

@ -0,0 +1,407 @@
import path from 'node:path'
import {
E2E_TIMEOUT,
setupPuppeteer,
} from '../../../packages/vue/__tests__/e2e/e2eUtils'
import connect from 'connect'
import sirv from 'sirv'
import { expect } from 'vitest'
const { page, nextFrame, timeout, html, transitionStart } = setupPuppeteer()
import { ports } from '../utils'
const duration = process.env.CI ? 200 : 50
const buffer = process.env.CI ? 50 : 20
const transitionFinish = (time = duration) => timeout(time + buffer)
describe('vapor transition-group', () => {
let server: any
const port = ports.transitionGroup
beforeAll(() => {
server = connect()
.use(sirv(path.resolve(import.meta.dirname, '../dist')))
.listen(port)
process.on('SIGTERM', () => server && server.close())
})
afterAll(() => {
server.close()
})
beforeEach(async () => {
const baseUrl = `http://localhost:${port}/transition-group/`
await page().goto(baseUrl)
await page().waitForSelector('#app')
})
test(
'enter',
async () => {
const btnSelector = '.enter > button'
const containerSelector = '.enter > div'
expect(await html(containerSelector)).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>`,
)
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test test-enter-from test-enter-active">d</div>` +
`<div class="test test-enter-from test-enter-active">e</div>`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test test-enter-active test-enter-to">d</div>` +
`<div class="test test-enter-active test-enter-to">e</div>`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test">d</div>` +
`<div class="test">e</div>`,
)
},
E2E_TIMEOUT,
)
test(
'leave',
async () => {
const btnSelector = '.leave > button'
const containerSelector = '.leave > div'
expect(await html(containerSelector)).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>`,
)
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(
`<div class="test test-leave-from test-leave-active">a</div>` +
`<div class="test">b</div>` +
`<div class="test test-leave-from test-leave-active">c</div>`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="test test-leave-active test-leave-to">a</div>` +
`<div class="test">b</div>` +
`<div class="test test-leave-active test-leave-to">c</div>`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(`<div class="test">b</div>`)
},
E2E_TIMEOUT,
)
test(
'enter + leave',
async () => {
const btnSelector = '.enter-leave > button'
const containerSelector = '.enter-leave > div'
expect(await html(containerSelector)).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>`,
)
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(
`<div class="test test-leave-from test-leave-active">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test test-enter-from test-enter-active">d</div>`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="test test-leave-active test-leave-to">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test test-enter-active test-enter-to">d</div>`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test">d</div>`,
)
},
E2E_TIMEOUT,
)
test(
'appear',
async () => {
const btnSelector = '.appear > button'
const containerSelector = '.appear > div'
expect(await html('.appear')).toBe(`<button>appear button</button>`)
await page().evaluate(() => {
return (window as any).setAppear()
})
// appear
expect(await html(containerSelector)).toBe(
`<div class="test test-appear-from test-appear-active">a</div>` +
`<div class="test test-appear-from test-appear-active">b</div>` +
`<div class="test test-appear-from test-appear-active">c</div>`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="test test-appear-active test-appear-to">a</div>` +
`<div class="test test-appear-active test-appear-to">b</div>` +
`<div class="test test-appear-active test-appear-to">c</div>`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>`,
)
// enter
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test test-enter-from test-enter-active">d</div>` +
`<div class="test test-enter-from test-enter-active">e</div>`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test test-enter-active test-enter-to">d</div>` +
`<div class="test test-enter-active test-enter-to">e</div>`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test">d</div>` +
`<div class="test">e</div>`,
)
},
E2E_TIMEOUT,
)
test(
'move',
async () => {
const btnSelector = '.move > button'
const containerSelector = '.move > div'
expect(await html(containerSelector)).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>`,
)
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(
`<div class="test group-enter-from group-enter-active">d</div>` +
`<div class="test">b</div>` +
`<div class="test group-move" style="">a</div>` +
`<div class="test group-leave-from group-leave-active group-move" style="">c</div>`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="test group-enter-active group-enter-to">d</div>` +
`<div class="test">b</div>` +
`<div class="test group-move" style="">a</div>` +
`<div class="test group-leave-active group-move group-leave-to" style="">c</div>`,
)
await transitionFinish(duration * 2)
expect(await html(containerSelector)).toBe(
`<div class="test">d</div>` +
`<div class="test">b</div>` +
`<div class="test" style="">a</div>`,
)
},
E2E_TIMEOUT,
)
test('dynamic name', async () => {
const btnSelector = '.dynamic-name button.toggleBtn'
const btnChangeName = '.dynamic-name button.changeNameBtn'
const containerSelector = '.dynamic-name > div'
expect(await html(containerSelector)).toBe(
`<div>a</div>` + `<div>b</div>` + `<div>c</div>`,
)
// invalid name
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(`<div>b</div>` + `<div>c</div>` + `<div>a</div>`)
// change name
expect(
(await transitionStart(btnChangeName, containerSelector)).innerHTML,
).toBe(
`<div class="group-move" style="">a</div>` +
`<div class="group-move" style="">b</div>` +
`<div class="group-move" style="">c</div>`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div class="" style="">a</div>` +
`<div class="" style="">b</div>` +
`<div class="" style="">c</div>`,
)
})
test('events', async () => {
const btnSelector = '.events > button'
const containerSelector = '.events > div'
expect(await html('.events')).toBe(`<button>events button</button>`)
await page().evaluate(() => {
return (window as any).setAppear()
})
// appear
expect(await html(containerSelector)).toBe(
`<div class="test test-appear-from test-appear-active">a</div>` +
`<div class="test test-appear-from test-appear-active">b</div>` +
`<div class="test test-appear-from test-appear-active">c</div>`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="test test-appear-active test-appear-to">a</div>` +
`<div class="test test-appear-active test-appear-to">b</div>` +
`<div class="test test-appear-active test-appear-to">c</div>`,
)
let calls = await page().evaluate(() => {
return (window as any).getCalls()
})
expect(calls).toContain('beforeAppear')
expect(calls).toContain('onAppear')
expect(calls).not.toContain('afterAppear')
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div class="test">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>`,
)
expect(
await page().evaluate(() => {
return (window as any).getCalls()
}),
).toContain('afterAppear')
// enter + leave
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(
`<div class="test test-leave-from test-leave-active">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test test-enter-from test-enter-active">d</div>`,
)
calls = await page().evaluate(() => {
return (window as any).getCalls()
})
expect(calls).toContain('beforeLeave')
expect(calls).toContain('onLeave')
expect(calls).not.toContain('afterLeave')
expect(calls).toContain('beforeEnter')
expect(calls).toContain('onEnter')
expect(calls).not.toContain('afterEnter')
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="test test-leave-active test-leave-to">a</div>` +
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test test-enter-active test-enter-to">d</div>`,
)
calls = await page().evaluate(() => {
return (window as any).getCalls()
})
expect(calls).not.toContain('afterLeave')
expect(calls).not.toContain('afterEnter')
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div class="test">b</div>` +
`<div class="test">c</div>` +
`<div class="test">d</div>`,
)
calls = await page().evaluate(() => {
return (window as any).getCalls()
})
expect(calls).toContain('afterLeave')
expect(calls).toContain('afterEnter')
})
test('interop: render vdom component', async () => {
const btnSelector = '.interop > button'
const containerSelector = '.interop > div'
expect(await html(containerSelector)).toBe(
`<div><div>a</div></div>` +
`<div><div>b</div></div>` +
`<div><div>c</div></div>`,
)
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(
`<div class="test-leave-from test-leave-active"><div>a</div></div>` +
`<div class="test-move" style=""><div>b</div></div>` +
`<div class="test-move" style=""><div>c</div></div>` +
`<div class="test-enter-from test-enter-active"><div>d</div></div>`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="test-leave-active test-leave-to"><div>a</div></div>` +
`<div class="test-move" style=""><div>b</div></div>` +
`<div class="test-move" style=""><div>c</div></div>` +
`<div class="test-enter-active test-enter-to"><div>d</div></div>`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div class="" style=""><div>b</div></div>` +
`<div class="" style=""><div>c</div></div>` +
`<div class=""><div>d</div></div>`,
)
})
})

File diff suppressed because it is too large Load Diff

View File

@ -5,12 +5,28 @@ import {
} from '../../../packages/vue/__tests__/e2e/e2eUtils' } from '../../../packages/vue/__tests__/e2e/e2eUtils'
import connect from 'connect' import connect from 'connect'
import sirv from 'sirv' import sirv from 'sirv'
const {
page,
click,
text,
enterValue,
html,
transitionStart,
waitForElement,
nextFrame,
timeout,
} = setupPuppeteer()
const duration = process.env.CI ? 200 : 50
const buffer = process.env.CI ? 50 : 20
const transitionFinish = (time = duration) => timeout(time + buffer)
import { ports } from '../utils'
import { nextTick } from 'vue'
describe('vdom / vapor interop', () => { describe('vdom / vapor interop', () => {
const { page, click, text, enterValue } = setupPuppeteer()
let server: any let server: any
const port = '8193' const port = ports.vdomInterop
beforeAll(() => { beforeAll(() => {
server = connect() server = connect()
.use(sirv(path.resolve(import.meta.dirname, '../dist'))) .use(sirv(path.resolve(import.meta.dirname, '../dist')))
@ -18,16 +34,25 @@ describe('vdom / vapor interop', () => {
process.on('SIGTERM', () => server && server.close()) process.on('SIGTERM', () => server && server.close())
}) })
beforeEach(async () => {
const baseUrl = `http://localhost:${port}/interop/`
await page().goto(baseUrl)
await page().waitForSelector('#app')
})
afterAll(() => { afterAll(() => {
server.close() server.close()
}) })
beforeEach(async () => {
const baseUrl = `http://localhost:${port}/interop/`
await page().goto(baseUrl)
await page().waitForSelector('#app')
})
test( test(
'should work', 'should work',
async () => { async () => {
const baseUrl = `http://localhost:${port}/interop/`
await page().goto(baseUrl)
expect(await text('.vapor > h2')).toContain('Vapor component in VDOM') expect(await text('.vapor > h2')).toContain('Vapor component in VDOM')
expect(await text('.vapor-prop')).toContain('hello') expect(await text('.vapor-prop')).toContain('hello')
@ -81,4 +106,205 @@ describe('vdom / vapor interop', () => {
}, },
E2E_TIMEOUT, E2E_TIMEOUT,
) )
describe('vdom transition', () => {
test(
'render vapor component',
async () => {
const btnSelector = '.trans-vapor > button'
const containerSelector = '.trans-vapor > div'
expect(await html(containerSelector)).toBe(`<div>vapor compA</div>`)
// comp leave
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(
`<div class="v-leave-from v-leave-active">vapor compA</div><!---->`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="v-leave-active v-leave-to">vapor compA</div><!---->`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(`<!---->`)
// comp enter
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(`<div class="v-enter-from v-enter-active">vapor compA</div>`)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="v-enter-active v-enter-to">vapor compA</div>`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div class="">vapor compA</div>`,
)
},
E2E_TIMEOUT,
)
test(
'switch between vdom/vapor component (out-in mode)',
async () => {
const btnSelector = '.trans-vdom-vapor-out-in > button'
const containerSelector = '.trans-vdom-vapor-out-in > div'
const childSelector = `${containerSelector} > div`
expect(await html(containerSelector)).toBe(`<div>vdom comp</div>`)
// switch to vapor comp
// vdom comp leave
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(
`<div class="fade-leave-from fade-leave-active">vdom comp</div><!---->`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="fade-leave-active fade-leave-to">vdom comp</div><!---->`,
)
// vapor comp enter
await waitForElement(childSelector, 'vapor compA', [
'fade-enter-from',
'fade-enter-active',
])
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="fade-enter-active fade-enter-to">vapor compA</div>`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div class="">vapor compA</div>`,
)
// switch to vdom comp
// vapor comp leave
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(
`<div class="fade-leave-from fade-leave-active">vapor compA</div><!---->`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="fade-leave-active fade-leave-to">vapor compA</div><!---->`,
)
// vdom comp enter
await waitForElement(childSelector, 'vdom comp', [
'fade-enter-from',
'fade-enter-active',
])
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div class="fade-enter-active fade-enter-to">vdom comp</div>`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div class="">vdom comp</div>`,
)
},
E2E_TIMEOUT,
)
})
describe('vdom transition-group', () => {
test(
'render vapor component',
async () => {
const btnSelector = '.trans-group-vapor > button'
const containerSelector = '.trans-group-vapor > div'
expect(await html(containerSelector)).toBe(
`<div><div>a</div></div>` +
`<div><div>b</div></div>` +
`<div><div>c</div></div>`,
)
// insert
expect(
(await transitionStart(btnSelector, containerSelector)).innerHTML,
).toBe(
`<div><div>a</div></div>` +
`<div><div>b</div></div>` +
`<div><div>c</div></div>` +
`<div class="test-enter-from test-enter-active"><div>d</div></div>` +
`<div class="test-enter-from test-enter-active"><div>e</div></div>`,
)
await nextFrame()
expect(await html(containerSelector)).toBe(
`<div><div>a</div></div>` +
`<div><div>b</div></div>` +
`<div><div>c</div></div>` +
`<div class="test-enter-active test-enter-to"><div>d</div></div>` +
`<div class="test-enter-active test-enter-to"><div>e</div></div>`,
)
await transitionFinish()
expect(await html(containerSelector)).toBe(
`<div><div>a</div></div>` +
`<div><div>b</div></div>` +
`<div><div>c</div></div>` +
`<div class=""><div>d</div></div>` +
`<div class=""><div>e</div></div>`,
)
},
E2E_TIMEOUT,
)
describe('teleport', () => {
const testSelector = '.teleport'
test('render vapor component', async () => {
const targetSelector = `${testSelector} .teleport-target`
const containerSelector = `${testSelector} .render-vapor-comp`
const buttonSelector = `${containerSelector} button`
// teleport is disabled by default
expect(await html(containerSelector)).toBe(
`<button>toggle</button><div>vapor comp</div>`,
)
expect(await html(targetSelector)).toBe('')
// disabled -> enabled
await click(buttonSelector)
await nextTick()
expect(await html(containerSelector)).toBe(`<button>toggle</button>`)
expect(await html(targetSelector)).toBe('<div>vapor comp</div>')
// enabled -> disabled
await click(buttonSelector)
await nextTick()
expect(await html(containerSelector)).toBe(
`<button>toggle</button><div>vapor comp</div>`,
)
expect(await html(targetSelector)).toBe('')
})
})
describe('async component', () => {
const container = '.async-component-interop'
test(
'with-vdom-inner-component',
async () => {
const testContainer = `${container} .with-vdom-component`
expect(await html(testContainer)).toBe('<span>loading...</span>')
await timeout(duration)
expect(await html(testContainer)).toBe('<div>foo</div>')
},
E2E_TIMEOUT,
)
})
})
}) })

View File

@ -1,2 +1,12 @@
<a href="/interop/">VDOM / Vapor interop</a> <a href="/interop/">VDOM / Vapor interop</a>
<a href="/todomvc/">Vapor TodoMVC</a> <a href="/todomvc/">Vapor TodoMVC</a>
<a href="/transition/">Vapor Transition</a>
<a href="/transition-group/">Vapor TransitionGroup</a>
<a href="/teleport/">Vapor Teleport</a>
<style>
a {
display: block;
margin: 10px;
}
</style>

View File

@ -1,9 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, shallowRef } from 'vue'
import VaporComp from './VaporComp.vue' import VaporComp from './components/VaporComp.vue'
import VaporCompA from '../transition/components/VaporCompA.vue'
import VdomComp from '../transition/components/VdomComp.vue'
import VaporSlot from '../transition/components/VaporSlot.vue'
import { defineVaporAsyncComponent, h } from 'vue'
import VdomFoo from './components/VdomFoo.vue'
const msg = ref('hello') const msg = ref('hello')
const passSlot = ref(true) const passSlot = ref(true)
const toggleVapor = ref(true)
const interopComponent = shallowRef(VdomComp)
function toggleInteropComponent() {
interopComponent.value =
interopComponent.value === VaporCompA ? VdomComp : VaporCompA
}
const items = ref(['a', 'b', 'c'])
const enterClick = () => items.value.push('d', 'e')
import SimpleVaporComp from './components/SimpleVaporComp.vue'
const disabled = ref(true)
const duration = typeof process !== 'undefined' && process.env.CI ? 200 : 50
const AsyncVDomFoo = defineVaporAsyncComponent({
loader: () => {
return new Promise(r => {
setTimeout(() => {
r(VdomFoo as any)
}, duration)
})
},
loadingComponent: () => h('span', 'loading...'),
})
</script> </script>
<template> <template>
@ -19,4 +49,59 @@ const passSlot = ref(true)
<template #test v-if="passSlot">A test slot</template> <template #test v-if="passSlot">A test slot</template>
</VaporComp> </VaporComp>
<!-- transition interop -->
<div>
<div class="trans-vapor">
<button @click="toggleVapor = !toggleVapor">
toggle vapor component
</button>
<div>
<Transition>
<VaporCompA v-if="toggleVapor" />
</Transition>
</div>
</div>
<div class="trans-vdom-vapor-out-in">
<button @click="toggleInteropComponent">
switch between vdom/vapor component out-in mode
</button>
<div>
<Transition name="fade" mode="out-in">
<component :is="interopComponent"></component>
</Transition>
</div>
</div>
</div>
<!-- transition-group interop -->
<div>
<div class="trans-group-vapor">
<button @click="enterClick">insert items</button>
<div>
<transition-group name="test">
<VaporSlot v-for="item in items" :key="item">
<div>{{ item }}</div>
</VaporSlot>
</transition-group>
</div>
</div>
</div>
<!-- teleport -->
<div class="teleport">
<div class="teleport-target"></div>
<div class="render-vapor-comp">
<button @click="disabled = !disabled">toggle</button>
<Teleport to=".teleport-target" defer :disabled="disabled">
<SimpleVaporComp />
</Teleport>
</div>
</div>
<!-- teleport end-->
<!-- async component -->
<div class="async-component-interop">
<div class="with-vdom-component">
<AsyncVDomFoo />
</div>
</div>
<!-- async component end -->
</template> </template>

View File

@ -0,0 +1,6 @@
<script setup vapor lang="ts">
const msg = 'vapor comp'
</script>
<template>
<div>{{ msg }}</div>
</template>

View File

@ -27,7 +27,8 @@ const slotProp = ref('slot prop')
change slot prop change slot prop
</button> </button>
<div class="vdom-slot-in-vapor-default"> <div class="vdom-slot-in-vapor-default">
#default: <slot :foo="slotProp" /> #default:
<slot :foo="slotProp" />
</div> </div>
<div class="vdom-slot-in-vapor-test"> <div class="vdom-slot-in-vapor-test">
#test: <slot name="test">fallback content</slot> #test: <slot name="test">fallback content</slot>
@ -40,7 +41,7 @@ const slotProp = ref('slot prop')
> >
Toggle default slot to vdom Toggle default slot to vdom
</button> </button>
<VdomComp :msg="msg"> <VdomComp :msg="msg" class="foo">
<template #default="{ foo }" v-if="passSlot"> <template #default="{ foo }" v-if="passSlot">
<div>slot prop: {{ foo }}</div> <div>slot prop: {{ foo }}</div>
<div>component prop: {{ msg }}</div> <div>component prop: {{ msg }}</div>

View File

@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>
<div>foo</div>
</template>

View File

@ -1,4 +1,5 @@
import { createApp, vaporInteropPlugin } from 'vue' import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue' import App from './App.vue'
import '../transition/style.css'
createApp(App).use(vaporInteropPlugin).mount('#app') createApp(App).use(vaporInteropPlugin).mount('#app')

View File

@ -0,0 +1,17 @@
<script setup vapor>
import { ref, Teleport } from 'vue'
import VdomComp from './components/VdomComp.vue'
const disabled = ref(true)
</script>
<template>
<div class="target"></div>
<div class="interop-render-vdom-comp">
<button @click="disabled = !disabled">toggle</button>
<div>
<Teleport to=".target" defer :disabled>
<VdomComp />
</Teleport>
</div>
</div>
</template>

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
const msg = 'vdom comp'
</script>
<template>
<h1>{{ msg }}</h1>
</template>

View File

@ -0,0 +1,2 @@
<script type="module" src="./main.ts"></script>
<div id="app"></div>

View File

@ -0,0 +1,5 @@
import { createVaporApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
import 'todomvc-app-css/index.css'
createVaporApp(App).use(vaporInteropPlugin).mount('#app')

View File

@ -0,0 +1,145 @@
<script setup vapor>
import { ref } from 'vue'
import VdomComp from './components/VdomComp.vue'
const items = ref(['a', 'b', 'c'])
const enterClick = () => items.value.push('d', 'e')
const leaveClick = () => (items.value = ['b'])
const enterLeaveClick = () => (items.value = ['b', 'c', 'd'])
const appear = ref(false)
window.setAppear = () => (appear.value = true)
const moveClick = () => (items.value = ['d', 'b', 'a'])
const name = ref('invalid')
const dynamicClick = () => (items.value = ['b', 'c', 'a'])
const changeName = () => {
name.value = 'group'
items.value = ['a', 'b', 'c']
}
let calls = []
window.getCalls = () => {
const ret = calls.slice()
calls = []
return ret
}
const eventsClick = () => (items.value = ['b', 'c', 'd'])
const interopClick = () => (items.value = ['b', 'c', 'd'])
</script>
<template>
<div class="transition-group-container">
<div class="enter">
<button @click="enterClick">enter button</button>
<div>
<transition-group name="test">
<div v-for="item in items" :key="item" class="test">{{ item }}</div>
</transition-group>
</div>
</div>
<div class="leave">
<button @click="leaveClick">leave button</button>
<div>
<transition-group name="test">
<div v-for="item in items" :key="item" class="test">{{ item }}</div>
</transition-group>
</div>
</div>
<div class="enter-leave">
<button @click="enterLeaveClick">enter-leave button</button>
<div>
<transition-group name="test">
<div v-for="item in items" :key="item" class="test">{{ item }}</div>
</transition-group>
</div>
</div>
<div class="appear">
<button @click="enterClick">appear button</button>
<div v-if="appear">
<transition-group
appear
appear-from-class="test-appear-from"
appear-to-class="test-appear-to"
appear-active-class="test-appear-active"
name="test"
>
<div v-for="item in items" :key="item" class="test">{{ item }}</div>
</transition-group>
</div>
</div>
<div class="move">
<button @click="moveClick">move button</button>
<div>
<transition-group name="group">
<div v-for="item in items" :key="item" class="test">{{ item }}</div>
</transition-group>
</div>
</div>
<div class="dynamic-name">
<button class="toggleBtn" @click="dynamicClick">dynamic button</button>
<button class="changeNameBtn" @click="changeName">change name</button>
<div>
<transition-group :name="name">
<div v-for="item in items" :key="item">{{ item }}</div>
</transition-group>
</div>
</div>
<div class="events">
<button @click="eventsClick">events button</button>
<div v-if="appear">
<transition-group
name="test"
appear
appear-from-class="test-appear-from"
appear-to-class="test-appear-to"
appear-active-class="test-appear-active"
@beforeEnter="() => calls.push('beforeEnter')"
@enter="() => calls.push('onEnter')"
@afterEnter="() => calls.push('afterEnter')"
@beforeLeave="() => calls.push('beforeLeave')"
@leave="() => calls.push('onLeave')"
@afterLeave="() => calls.push('afterLeave')"
@beforeAppear="() => calls.push('beforeAppear')"
@appear="() => calls.push('onAppear')"
@afterAppear="() => calls.push('afterAppear')"
>
<div v-for="item in items" :key="item" class="test">{{ item }}</div>
</transition-group>
</div>
</div>
<div class="interop">
<button @click="interopClick">interop button</button>
<div>
<transition-group name="test">
<VdomComp v-for="item in items" :key="item">
<div>{{ item }}</div>
</VdomComp>
</transition-group>
</div>
</div>
</div>
</template>
<style>
.transition-group-container > div {
padding: 15px;
border: 1px solid #f7f7f7;
margin-top: 15px;
}
.test-move,
.test-enter-active,
.test-leave-active {
transition: all 50ms cubic-bezier(0.55, 0, 0.1, 1);
}
.test-enter-from,
.test-leave-to {
opacity: 0;
transform: scaleY(0.01) translate(30px, 0);
}
.test-leave-active {
position: absolute;
}
</style>

View File

@ -0,0 +1,9 @@
<script vapor>
const msg = 'vapor comp'
</script>
<template>
<div>
<slot />
</div>
</template>

View File

@ -0,0 +1,9 @@
<script setup>
const msg = 'vdom comp'
</script>
<template>
<div>
<slot />
</div>
</template>

View File

@ -0,0 +1,2 @@
<script type="module" src="./main.ts"></script>
<div id="app"></div>

View File

@ -0,0 +1,5 @@
import { createVaporApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
import '../../../packages/vue/__tests__/e2e/style.css'
createVaporApp(App).use(vaporInteropPlugin).mount('#app')

View File

@ -0,0 +1,528 @@
<script vapor>
import {
createComponent,
defineVaporComponent,
ref,
shallowRef,
VaporTransition,
createIf,
template,
} from 'vue'
const show = ref(true)
const toggle = ref(true)
const count = ref(0)
const timeout = (fn, time) => setTimeout(fn, time)
const duration = typeof process !== 'undefined' && process.env.CI ? 200 : 50
let calls = {
basic: [],
withoutAppear: [],
withArgs: [],
enterCancel: [],
withAppear: [],
cssFalse: [],
ifInOut: [],
show: [],
showLeaveCancel: [],
showAppear: [],
notEnter: [],
}
window.getCalls = key => calls[key]
window.resetCalls = key => (calls[key] = [])
import VaporCompA from './components/VaporCompA.vue'
import VaporCompB from './components/VaporCompB.vue'
const activeComponent = shallowRef(VaporCompB)
function toggleComponent() {
activeComponent.value =
activeComponent.value === VaporCompA ? VaporCompB : VaporCompA
}
const toggleVdom = ref(true)
import VDomComp from './components/VdomComp.vue'
const interopComponent = shallowRef(VDomComp)
function toggleInteropComponent() {
interopComponent.value =
interopComponent.value === VaporCompA ? VDomComp : VaporCompA
}
const name = ref('test')
const MyTransition = defineVaporComponent((props, { slots }) => {
return createComponent(VaporTransition, { name: () => 'test' }, slots)
})
const MyTransitionFallthroughAttr = defineVaporComponent((props, { slots }) => {
return createComponent(
VaporTransition,
{ foo: () => 1, name: () => 'test' },
slots,
)
})
const One = defineVaporComponent({
setup() {
return createIf(
() => false,
() => template('<div>one</div>', true)(),
)
},
})
const Two = defineVaporComponent({
setup() {
return template('<div>two</div>', true)()
},
})
const view = shallowRef(One)
function changeView() {
view.value = view.value === One ? Two : One
}
const SimpleOne = defineVaporComponent({
setup() {
return template('<div>one</div>', true)()
},
})
const viewInOut = shallowRef(SimpleOne)
function changeViewInOut() {
viewInOut.value = viewInOut.value === SimpleOne ? Two : SimpleOne
}
</script>
<template>
<div class="transition-container">
<!-- work with vif -->
<div class="if-basic">
<div>
<transition>
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">basic toggle</button>
</div>
<div class="if-named">
<div>
<transition name="test">
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="if-custom-classes">
<div>
<transition
enter-from-class="hello-from"
enter-active-class="hello-active"
enter-to-class="hello-to"
leave-from-class="bye-from"
leave-active-class="bye-active"
leave-to-class="bye-to"
>
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="if-dynamic-name">
<div>
<transition :name="name">
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button class="toggle" @click="toggle = !toggle">button</button>
<button class="change" @click="name = 'changed'">{{ name }}</button>
</div>
<div class="if-events-without-appear">
<div>
<transition
name="test"
@before-enter="() => calls.withoutAppear.push('beforeEnter')"
@enter="() => calls.withoutAppear.push('onEnter')"
@after-enter="() => calls.withoutAppear.push('afterEnter')"
@beforeLeave="() => calls.withoutAppear.push('beforeLeave')"
@leave="() => calls.withoutAppear.push('onLeave')"
@afterLeave="() => calls.withoutAppear.push('afterLeave')"
>
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="if-events-with-args">
<div>
<transition
:css="false"
name="test"
@before-enter="
el => {
calls.withArgs.push('beforeEnter')
el.classList.add('before-enter')
}
"
@enter="
(el, done) => {
calls.withArgs.push('onEnter')
el.classList.add('enter')
timeout(done, 200)
}
"
@after-enter="
el => {
calls.withArgs.push('afterEnter')
el.classList.add('after-enter')
}
"
@before-leave="
el => {
calls.withArgs.push('beforeLeave')
el.classList.add('before-leave')
}
"
@leave="
(el, done) => {
calls.withArgs.push('onLeave')
el.classList.add('leave')
timeout(done, 200)
}
"
@after-leave="
() => {
calls.withArgs.push('afterLeave')
}
"
>
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="if-enter-cancelled">
<div>
<transition
name="test"
@enter-cancelled="
() => {
calls.enterCancel.push('enterCancelled')
}
"
>
<div v-if="!toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">cancelled</button>
</div>
<div class="if-appear">
<div>
<transition
name="test"
appear
appear-from-class="test-appear-from"
appear-to-class="test-appear-to"
appear-active-class="test-appear-active"
>
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="if-events-with-appear">
<div>
<transition
name="test"
appear
appear-from-class="test-appear-from"
appear-to-class="test-appear-to"
appear-active-class="test-appear-active"
@beforeEnter="() => calls.withAppear.push('beforeEnter')"
@enter="() => calls.withAppear.push('onEnter')"
@afterEnter="() => calls.withAppear.push('afterEnter')"
@beforeLeave="() => calls.withAppear.push('beforeLeave')"
@leave="() => calls.withAppear.push('onLeave')"
@afterLeave="() => calls.withAppear.push('afterLeave')"
@beforeAppear="() => calls.withAppear.push('beforeAppear')"
@appear="() => calls.withAppear.push('onAppear')"
@afterAppear="() => calls.withAppear.push('afterAppear')"
>
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="if-css-false">
<div>
<transition
:css="false"
name="test"
@beforeEnter="() => calls.cssFalse.push('beforeEnter')"
@enter="() => calls.cssFalse.push('onEnter')"
@afterEnter="() => calls.cssFalse.push('afterEnter')"
@beforeLeave="() => calls.cssFalse.push('beforeLeave')"
@leave="() => calls.cssFalse.push('onLeave')"
@afterLeave="() => calls.cssFalse.push('afterLeave')"
>
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle"></button>
</div>
<div class="if-no-trans">
<div>
<transition name="noop">
<div v-if="toggle">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="if-ani">
<div>
<transition name="test-anim">
<div v-if="toggle">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="if-ani-explicit-type">
<div>
<transition name="test-anim-long" type="animation">
<div v-if="toggle">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="if-high-order">
<div>
<MyTransition>
<div v-if="toggle" class="test">content</div>
</MyTransition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="if-empty-root">
<div>
<transition name="test">
<component class="test" :is="view"></component>
</transition>
</div>
<button class="toggle" @click="toggle = !toggle">button</button>
<button class="change" @click="changeView">changeView button</button>
</div>
<div class="if-at-component-root-level">
<div>
<transition name="test" mode="out-in">
<component class="test" :is="view"></component>
</transition>
</div>
<button class="toggle" @click="toggle = !toggle">button</button>
<button class="change" @click="changeView">changeView button</button>
</div>
<div class="if-fallthrough-attr">
<div>
<MyTransitionFallthroughAttr>
<div v-if="toggle">content</div>
</MyTransitionFallthroughAttr>
</div>
<button @click="toggle = !toggle">button fallthrough</button>
</div>
<div class="if-fallthrough-attr-in-out">
<div>
<transition
foo="1"
name="test"
mode="in-out"
@beforeEnter="() => calls.ifInOut.push('beforeEnter')"
@enter="() => calls.ifInOut.push('onEnter')"
@afterEnter="() => calls.ifInOut.push('afterEnter')"
@beforeLeave="() => calls.ifInOut.push('beforeLeave')"
@leave="() => calls.ifInOut.push('onLeave')"
@afterLeave="() => calls.ifInOut.push('afterLeave')"
>
<component :is="viewInOut"></component>
</transition>
</div>
<button @click="changeViewInOut">button</button>
</div>
<!-- work with vif end -->
<!-- work with vshow -->
<div class="show-named">
<div>
<transition name="test">
<div v-show="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="show-events">
<div>
<transition
name="test"
@beforeEnter="() => calls.show.push('beforeEnter')"
@enter="() => calls.show.push('onEnter')"
@afterEnter="() => calls.show.push('afterEnter')"
@beforeLeave="() => calls.show.push('beforeLeave')"
@leave="() => calls.show.push('onLeave')"
@afterLeave="() => calls.show.push('afterLeave')"
>
<div v-show="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="show-leave-cancelled">
<div>
<transition
name="test"
@leave-cancelled="() => calls.showLeaveCancel.push('leaveCancelled')"
>
<div v-show="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">leave cancelled</button>
</div>
<div class="show-appear">
<div>
<transition
name="test"
appear
appear-from-class="test-appear-from"
appear-to-class="test-appear-to"
appear-active-class="test-appear-active"
@beforeEnter="() => calls.showAppear.push('beforeEnter')"
@enter="() => calls.showAppear.push('onEnter')"
@afterEnter="() => calls.showAppear.push('afterEnter')"
>
<div v-show="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="show-appear-not-enter">
<div>
<transition
name="test"
appear
@beforeEnter="() => calls.notEnter.push('beforeEnter')"
@enter="() => calls.notEnter.push('onEnter')"
@afterEnter="() => calls.notEnter.push('afterEnter')"
>
<div v-show="!toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<!-- work with vshow end -->
<!-- explicit durations -->
<div class="duration-single-value">
<div>
<transition name="test" :duration="duration * 2">
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="duration-enter">
<div>
<transition name="test" :duration="{ enter: duration * 2 }">
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="duration-leave">
<div>
<transition name="test" :duration="{ leave: duration * 2 }">
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<div class="duration-enter-leave">
<div>
<transition
name="test"
:duration="{ enter: duration * 4, leave: duration * 2 }"
>
<div v-if="toggle" class="test">content</div>
</transition>
</div>
<button @click="toggle = !toggle">button</button>
</div>
<!-- explicit durations end -->
<!-- keyed fragment -->
<div class="keyed">
<button @click="count++">inc</button>
<Transition>
<h1 style="position: absolute" :key="count">{{ count }}</h1>
</Transition>
</div>
<!-- keyed fragment end -->
<!-- mode -->
<div class="out-in">
<button @click="toggleComponent">toggle out-in</button>
<div>
<Transition name="fade" mode="out-in">
<component :is="activeComponent"></component>
</Transition>
</div>
</div>
<div class="in-out">
<button @click="toggleComponent">toggle in-out</button>
<div>
<Transition name="fade" mode="in-out">
<component :is="activeComponent"></component>
</Transition>
</div>
</div>
<!-- mode end -->
<!-- vdom interop -->
<div class="vdom">
<button @click="toggleVdom = !toggleVdom">toggle vdom component</button>
<div>
<Transition>
<VDomComp v-if="toggleVdom" />
</Transition>
</div>
</div>
<div class="vdom-vapor-out-in">
<button @click="toggleInteropComponent">
switch between vdom/vapor component out-in mode
</button>
<div>
<Transition name="fade" mode="out-in">
<component :is="interopComponent"></component>
</Transition>
</div>
</div>
<div class="vdom-vapor-in-out">
<button @click="toggleVdom = !toggleVdom">
switch between vdom/vapor component in-out mode
</button>
<div>
<Transition name="fade" mode="in-out">
<VaporCompA v-if="toggleVdom" />
<VDomComp v-else></VDomComp>
</Transition>
</div>
</div>
<!-- vdom interop end -->
</div>
</template>
<style>
.keyed {
height: 100px;
}
</style>
<style>
.transition-container > div {
padding: 15px;
border: 1px solid #f7f7f7;
margin-top: 15px;
}
</style>

View File

@ -0,0 +1,6 @@
<script setup vapor lang="ts">
const msg = 'vapor compA'
</script>
<template>
<div>{{ msg }}</div>
</template>

View File

@ -0,0 +1,6 @@
<script setup vapor lang="ts">
const msg = 'vapor compB'
</script>
<template>
<div>{{ msg }}</div>
</template>

View File

@ -0,0 +1,8 @@
<script setup vapor lang="ts">
const msg = 'vapor'
</script>
<template>
<div>
<slot></slot>
</div>
</template>

View File

@ -0,0 +1,6 @@
<script setup lang="ts">
const msg = 'vdom comp'
</script>
<template>
<div>{{ msg }}</div>
</template>

View File

@ -0,0 +1,2 @@
<script type="module" src="./main.ts"></script>
<div id="app"></div>

View File

@ -0,0 +1,6 @@
import { createVaporApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
import '../../../packages/vue/__tests__/e2e/style.css'
import './style.css'
createVaporApp(App).use(vaporInteropPlugin).mount('#app')

View File

@ -0,0 +1,35 @@
.v-enter-active,
.v-leave-active {
transition: opacity 50ms ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 50ms ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.test-move,
.test-enter-active,
.test-leave-active {
transition: all 50ms cubic-bezier(0.55, 0, 0.1, 1);
}
.test-enter-from,
.test-leave-to {
opacity: 0;
transform: scaleY(0.01) translate(30px, 0);
}
.test-leave-active {
position: absolute;
}

View File

@ -0,0 +1,8 @@
// make sure these ports are unique
export const ports = {
vdomInterop: 8193,
todomvc: 8194,
transition: 8195,
transitionGroup: 8196,
teleport: 8197,
}

View File

@ -14,6 +14,12 @@ export default defineConfig({
input: { input: {
interop: resolve(import.meta.dirname, 'interop/index.html'), interop: resolve(import.meta.dirname, 'interop/index.html'),
todomvc: resolve(import.meta.dirname, 'todomvc/index.html'), todomvc: resolve(import.meta.dirname, 'todomvc/index.html'),
teleport: resolve(import.meta.dirname, 'teleport/index.html'),
transition: resolve(import.meta.dirname, 'transition/index.html'),
transitionGroup: resolve(
import.meta.dirname,
'transition-group/index.html',
),
}, },
}, },
}, },

View File

@ -76,4 +76,5 @@ export {
} from './errors' } from './errors'
export { resolveModifiers } from './transforms/vOn' export { resolveModifiers } from './transforms/vOn'
export { isValidHTMLNesting } from './htmlNesting' export { isValidHTMLNesting } from './htmlNesting'
export { postTransformTransition } from './transforms/Transition'
export * from '@vue/compiler-core' export * from '@vue/compiler-core'

View File

@ -1,4 +1,5 @@
import { import {
type CompilerError,
type ComponentNode, type ComponentNode,
ElementTypes, ElementTypes,
type IfBranchNode, type IfBranchNode,
@ -15,22 +16,30 @@ export const transformTransition: NodeTransform = (node, context) => {
) { ) {
const component = context.isBuiltInComponent(node.tag) const component = context.isBuiltInComponent(node.tag)
if (component === TRANSITION) { if (component === TRANSITION) {
return postTransformTransition(node, context.onError)
}
}
}
export function postTransformTransition(
node: ComponentNode,
onError: (error: CompilerError) => void,
hasMultipleChildren: (
node: ComponentNode,
) => boolean = defaultHasMultipleChildren,
): () => void {
return () => { return () => {
if (!node.children.length) { if (!node.children.length) {
return return
} }
// warn multiple transition children
if (hasMultipleChildren(node)) { if (hasMultipleChildren(node)) {
context.onError( onError(
createDOMCompilerError( createDOMCompilerError(DOMErrorCodes.X_TRANSITION_INVALID_CHILDREN, {
DOMErrorCodes.X_TRANSITION_INVALID_CHILDREN,
{
start: node.children[0].loc.start, start: node.children[0].loc.start,
end: node.children[node.children.length - 1].loc.end, end: node.children[node.children.length - 1].loc.end,
source: '', source: '',
}, }),
),
) )
} }
@ -51,11 +60,11 @@ export const transformTransition: NodeTransform = (node, context) => {
} }
} }
} }
}
}
} }
function hasMultipleChildren(node: ComponentNode | IfBranchNode): boolean { function defaultHasMultipleChildren(
node: ComponentNode | IfBranchNode,
): boolean {
// #1352 filter out potential comment nodes. // #1352 filter out potential comment nodes.
const children = (node.children = node.children.filter( const children = (node.children = node.children.filter(
c => c =>
@ -66,6 +75,7 @@ function hasMultipleChildren(node: ComponentNode | IfBranchNode): boolean {
return ( return (
children.length !== 1 || children.length !== 1 ||
child.type === NodeTypes.FOR || child.type === NodeTypes.FOR ||
(child.type === NodeTypes.IF && child.branches.some(hasMultipleChildren)) (child.type === NodeTypes.IF &&
child.branches.some(defaultHasMultipleChildren))
) )
} }

View File

@ -984,7 +984,7 @@ export function compileScript(
ctx.s.prependLeft( ctx.s.prependLeft(
startOffset, startOffset,
`\n${genDefaultAs} /*@__PURE__*/${ctx.helper( `\n${genDefaultAs} /*@__PURE__*/${ctx.helper(
vapor ? `defineVaporComponent` : `defineComponent`, vapor && !ssr ? `defineVaporComponent` : `defineComponent`,
)}({${def}${runtimeOptions}\n ${ )}({${def}${runtimeOptions}\n ${
hasAwait ? `async ` : `` hasAwait ? `async ` : ``
}setup(${args}) {\n${exposeCall}`, }setup(${args}) {\n${exposeCall}`,

View File

@ -39,6 +39,7 @@ describe('ssr: components', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
_ssrRenderVNode(_push, _createVNode(_resolveDynamicComponent("foo"), _mergeProps({ prop: "b" }, _attrs), null), _parent) _ssrRenderVNode(_push, _createVNode(_resolveDynamicComponent("foo"), _mergeProps({ prop: "b" }, _attrs), null), _parent)
_push(\`<!--dynamic-component-->\`)
}" }"
`) `)
@ -49,6 +50,7 @@ describe('ssr: components', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
_ssrRenderVNode(_push, _createVNode(_resolveDynamicComponent(_ctx.foo), _mergeProps({ prop: "b" }, _attrs), null), _parent) _ssrRenderVNode(_push, _createVNode(_resolveDynamicComponent(_ctx.foo), _mergeProps({ prop: "b" }, _attrs), null), _parent)
_push(\`<!--dynamic-component-->\`)
}" }"
`) `)
}) })
@ -244,7 +246,8 @@ describe('ssr: components', () => {
_ssrRenderList(list, (i) => { _ssrRenderList(list, (i) => {
_push(\`<span\${_scopeId}></span>\`) _push(\`<span\${_scopeId}></span>\`)
}) })
_push(\`<!--]--></div>\`) _push(\`<!--]--><!--for--></div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -267,7 +270,8 @@ describe('ssr: components', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<span\${_scopeId}></span>\`) _push(\`<span\${_scopeId}></span>\`)
}) })
_push(\`<!--]--></div>\`) _push(\`<!--]--><!--for--></div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -361,6 +365,7 @@ describe('ssr: components', () => {
_push(\`\`) _push(\`\`)
if (false) { if (false) {
_push(\`<div\${_scopeId}></div>\`) _push(\`<div\${_scopeId}></div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }

View File

@ -396,4 +396,50 @@ describe('ssr: element', () => {
`) `)
}) })
}) })
describe('dynamic anchor', () => {
test('two consecutive components', () => {
expect(
getCompiledString(`
<div>
<div/>
<Comp1/>
<Comp2/>
<div/>
</div>
`),
).toMatchInlineSnapshot(`
"\`<div><div></div>\`)
_push(_ssrRenderComponent(_component_Comp1, null, null, _parent))
_push(\`<!--[[-->\`)
_push(_ssrRenderComponent(_component_Comp2, null, null, _parent))
_push(\`<!--]]--><div></div></div>\`"
`)
})
test('multiple consecutive components', () => {
expect(
getCompiledString(`
<div>
<div/>
<Comp1/>
<Comp2/>
<Comp3/>
<Comp4/>
<div/>
</div>
`),
).toMatchInlineSnapshot(`
"\`<div><div></div>\`)
_push(_ssrRenderComponent(_component_Comp1, null, null, _parent))
_push(\`<!--[[-->\`)
_push(_ssrRenderComponent(_component_Comp2, null, null, _parent))
_push(\`<!--]]--><!--[[-->\`)
_push(_ssrRenderComponent(_component_Comp3, null, null, _parent))
_push(\`<!--]]-->\`)
_push(_ssrRenderComponent(_component_Comp4, null, null, _parent))
_push(\`<div></div></div>\`"
`)
})
})
}) })

View File

@ -29,6 +29,7 @@ describe('ssr: attrs fallthrough', () => {
_push(\`<!--[-->\`) _push(\`<!--[-->\`)
if (true) { if (true) {
_push(\`<div></div>\`) _push(\`<div></div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }

View File

@ -70,6 +70,7 @@ describe('ssr: inject <style vars>', () => {
const _cssVars = { style: { color: _ctx.color }} const _cssVars = { style: { color: _ctx.color }}
if (_ctx.ok) { if (_ctx.ok) {
_push(\`<div\${_ssrRenderAttrs(_mergeProps(_attrs, _cssVars))}></div>\`) _push(\`<div\${_ssrRenderAttrs(_mergeProps(_attrs, _cssVars))}></div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!--[--><div\${ _push(\`<!--[--><div\${
_ssrRenderAttrs(_cssVars) _ssrRenderAttrs(_cssVars)

View File

@ -153,6 +153,7 @@ describe('ssr: <slot>', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (true) { if (true) {
_ssrRenderSlotInner(_ctx.$slots, "default", {}, null, _push, _parent, null, true) _ssrRenderSlotInner(_ctx.$slots, "default", {}, null, _push, _parent, null, true)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }

View File

@ -15,7 +15,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--for--><!--]-->\`)
}" }"
`) `)
}) })
@ -33,7 +33,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`</ul>\`) _push(\`<!--for--></ul>\`)
}" }"
`) `)
}) })
@ -52,8 +52,10 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--for-->\`)
if (false) { if (false) {
_push(\`<div></div>\`) _push(\`<div></div>\`)
_push(\`<!--if-->\`)
} }
_push(\`</ul>\`) _push(\`</ul>\`)
}" }"
@ -74,7 +76,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`</ul>\`) _push(\`<!--for--></ul>\`)
}" }"
`) `)
}) })
@ -96,7 +98,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`</\${_ctx.someTag}>\`) _push(\`<!--for--></\${_ctx.someTag}>\`)
}" }"
`) `)
}) })
@ -118,11 +120,14 @@ describe('transition-group', () => {
_ssrRenderList(10, (i) => { _ssrRenderList(10, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--for-->\`)
_ssrRenderList(10, (i) => { _ssrRenderList(10, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--for-->\`)
if (_ctx.ok) { if (_ctx.ok) {
_push(\`<div>ok</div>\`) _push(\`<div>ok</div>\`)
_push(\`<!--if-->\`)
} }
_push(\`<!--]-->\`) _push(\`<!--]-->\`)
}" }"

View File

@ -10,7 +10,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -25,7 +25,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div>foo<span>bar</span></div>\`) _push(\`<div>foo<span>bar</span></div>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -51,9 +51,9 @@ describe('ssr: v-for', () => {
_ssrInterpolate(j) _ssrInterpolate(j)
}</div>\`) }</div>\`)
}) })
_push(\`<!--]--></div>\`) _push(\`<!--]--><!--for--></div>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -68,7 +68,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<!--[-->\${_ssrInterpolate(i)}<!--]-->\`) _push(\`<!--[-->\${_ssrInterpolate(i)}<!--]-->\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -85,7 +85,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<span>\${_ssrInterpolate(i)}</span>\`) _push(\`<span>\${_ssrInterpolate(i)}</span>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -107,7 +107,7 @@ describe('ssr: v-for', () => {
_ssrInterpolate(i + 1) _ssrInterpolate(i + 1)
}</span><!--]-->\`) }</span><!--]-->\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })
@ -127,7 +127,7 @@ describe('ssr: v-for', () => {
_ssrRenderList(_ctx.list, ({ foo }, index) => { _ssrRenderList(_ctx.list, ({ foo }, index) => {
_push(\`<div>\${_ssrInterpolate(foo + _ctx.bar + index)}</div>\`) _push(\`<div>\${_ssrInterpolate(foo + _ctx.bar + index)}</div>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
}" }"
`) `)
}) })

View File

@ -8,6 +8,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -23,6 +24,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}>hello<span>ok</span></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}>hello<span>ok</span></div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -38,6 +40,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`) _push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`)
} }
@ -53,8 +56,10 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--if-->\`)
} else if (_ctx.bar) { } else if (_ctx.bar) {
_push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`) _push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -70,8 +75,10 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
_push(\`<!--if-->\`)
} else if (_ctx.bar) { } else if (_ctx.bar) {
_push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`) _push(\`<span\${_ssrRenderAttrs(_attrs)}></span>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<p\${_ssrRenderAttrs(_attrs)}></p>\`) _push(\`<p\${_ssrRenderAttrs(_attrs)}></p>\`)
} }
@ -86,6 +93,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<!--[-->hello<!--]-->\`) _push(\`<!--[-->hello<!--]-->\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -102,6 +110,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<div\${_ssrRenderAttrs(_attrs)}>hi</div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}>hi</div>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -118,6 +127,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<!--[--><div>hi</div><div>ho</div><!--]-->\`) _push(\`<!--[--><div>hi</div><div>ho</div><!--]-->\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -137,7 +147,8 @@ describe('ssr: v-if', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--]--><!--for-->\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }
@ -156,6 +167,7 @@ describe('ssr: v-if', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) { return function ssrRender(_ctx, _push, _parent, _attrs) {
if (_ctx.foo) { if (_ctx.foo) {
_push(\`<!--[--><div>hi</div><div>ho</div><!--]-->\`) _push(\`<!--[--><div>hi</div><div>ho</div><!--]-->\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`) _push(\`<div\${_ssrRenderAttrs(_attrs)}></div>\`)
} }

View File

@ -70,7 +70,7 @@ describe('ssr: v-model', () => {
: _ssrLooseEqual(_ctx.model, i))) ? " selected" : "" : _ssrLooseEqual(_ctx.model, i))) ? " selected" : ""
}></option>\`) }></option>\`)
}) })
_push(\`<!--]--></select></div>\`) _push(\`<!--]--><!--for--></select></div>\`)
}" }"
`) `)
@ -91,6 +91,7 @@ describe('ssr: v-model', () => {
? _ssrLooseContain(_ctx.model, _ctx.i) ? _ssrLooseContain(_ctx.model, _ctx.i)
: _ssrLooseEqual(_ctx.model, _ctx.i))) ? " selected" : "" : _ssrLooseEqual(_ctx.model, _ctx.i))) ? " selected" : ""
}></option>\`) }></option>\`)
_push(\`<!--if-->\`)
} else { } else {
_push(\`<!---->\`) _push(\`<!---->\`)
} }

View File

@ -7,6 +7,7 @@ import {
type IfStatement, type IfStatement,
type JSChildNode, type JSChildNode,
NodeTypes, NodeTypes,
type PlainElementNode,
type RootNode, type RootNode,
type TemplateChildNode, type TemplateChildNode,
type TemplateLiteral, type TemplateLiteral,
@ -20,7 +21,12 @@ import {
isText, isText,
processExpression, processExpression,
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import { escapeHtml, isString } from '@vue/shared' import {
DYNAMIC_END_ANCHOR_LABEL,
DYNAMIC_START_ANCHOR_LABEL,
escapeHtml,
isString,
} from '@vue/shared'
import { SSR_INTERPOLATE, ssrHelpers } from './runtimeHelpers' import { SSR_INTERPOLATE, ssrHelpers } from './runtimeHelpers'
import { ssrProcessIf } from './transforms/ssrVIf' import { ssrProcessIf } from './transforms/ssrVIf'
import { ssrProcessFor } from './transforms/ssrVFor' import { ssrProcessFor } from './transforms/ssrVFor'
@ -157,13 +163,33 @@ export function processChildren(
asFragment = false, asFragment = false,
disableNestedFragments = false, disableNestedFragments = false,
disableComment = false, disableComment = false,
asDynamic = false,
): void { ): void {
if (asDynamic) {
context.pushStringPart(`<!--${DYNAMIC_START_ANCHOR_LABEL}-->`)
}
if (asFragment) { if (asFragment) {
context.pushStringPart(`<!--[-->`) context.pushStringPart(`<!--[-->`)
} }
const { children } = parent
const { children, type, tagType } = parent as PlainElementNode
const inElement =
type === NodeTypes.ELEMENT && tagType === ElementTypes.ELEMENT
if (inElement) processChildrenDynamicInfo(children)
for (let i = 0; i < children.length; i++) { for (let i = 0; i < children.length; i++) {
const child = children[i] const child = children[i]
if (inElement && shouldProcessChildAsDynamic(parent, child)) {
processChildren(
{ children: [child] },
context,
asFragment,
disableNestedFragments,
disableComment,
true,
)
continue
}
switch (child.type) { switch (child.type) {
case NodeTypes.ELEMENT: case NodeTypes.ELEMENT:
switch (child.tagType) { switch (child.tagType) {
@ -237,6 +263,9 @@ export function processChildren(
if (asFragment) { if (asFragment) {
context.pushStringPart(`<!--]-->`) context.pushStringPart(`<!--]-->`)
} }
if (asDynamic) {
context.pushStringPart(`<!--${DYNAMIC_END_ANCHOR_LABEL}-->`)
}
} }
export function processChildrenAsStatement( export function processChildrenAsStatement(
@ -249,3 +278,147 @@ export function processChildrenAsStatement(
processChildren(parent, childContext, asFragment) processChildren(parent, childContext, asFragment)
return createBlockStatement(childContext.body) return createBlockStatement(childContext.body)
} }
const isStaticChildNode = (c: TemplateChildNode): boolean =>
(c.type === NodeTypes.ELEMENT && c.tagType !== ElementTypes.COMPONENT) ||
c.type === NodeTypes.TEXT ||
c.type === NodeTypes.COMMENT
interface DynamicInfo {
hasStaticPrevious: boolean
hasStaticNext: boolean
prevDynamicCount: number
nextDynamicCount: number
}
function processChildrenDynamicInfo(
children: (TemplateChildNode & { _ssrDynamicInfo?: DynamicInfo })[],
): void {
const filteredChildren = children.filter(
child => !(child.type === NodeTypes.TEXT && !child.content.trim()),
)
for (let i = 0; i < filteredChildren.length; i++) {
const child = filteredChildren[i]
if (
isStaticChildNode(child) ||
// fragment has it's own anchor, which can be used to distinguish the boundary
isFragmentChild(child)
) {
continue
}
child._ssrDynamicInfo = {
hasStaticPrevious: false,
hasStaticNext: false,
prevDynamicCount: 0,
nextDynamicCount: 0,
}
const info = child._ssrDynamicInfo
// Calculate the previous static and dynamic node counts
let foundStaticPrev = false
let dynamicCountPrev = 0
for (let j = i - 1; j >= 0; j--) {
const prevChild = filteredChildren[j]
if (isStaticChildNode(prevChild)) {
foundStaticPrev = true
break
}
// if the previous child has dynamic info, use it
else if (prevChild._ssrDynamicInfo) {
foundStaticPrev = prevChild._ssrDynamicInfo.hasStaticPrevious
dynamicCountPrev = prevChild._ssrDynamicInfo.prevDynamicCount + 1
break
}
dynamicCountPrev++
}
info.hasStaticPrevious = foundStaticPrev
info.prevDynamicCount = dynamicCountPrev
// Calculate the number of static and dynamic nodes afterwards
let foundStaticNext = false
let dynamicCountNext = 0
for (let j = i + 1; j < filteredChildren.length; j++) {
const nextChild = filteredChildren[j]
if (isStaticChildNode(nextChild)) {
foundStaticNext = true
break
}
// if the next child has dynamic info, use it
else if (nextChild._ssrDynamicInfo) {
foundStaticNext = nextChild._ssrDynamicInfo.hasStaticNext
dynamicCountNext = nextChild._ssrDynamicInfo.nextDynamicCount + 1
break
}
dynamicCountNext++
}
info.hasStaticNext = foundStaticNext
info.nextDynamicCount = dynamicCountNext
}
}
/**
* Check if a node should be processed as dynamic child.
* This is primarily used in Vapor mode hydration to wrap dynamic parts
* with markers (`<!--[[-->` and `<!--]]-->`).
* The purpose is to distinguish the boundaries of nodes during vapor hydration
*
* 1. two consecutive dynamic nodes should only wrap the second one
* <element>
* <element/> // Static node
* <Comp/> // Dynamic node -> should NOT be wrapped
* <Comp/> // Dynamic node -> should be wrapped
* <element/> // Static node
* </element>
*
* 2. three or more consecutive dynamic nodes should only wrap the
* middle nodes, leaving the first and last static.
* <element>
* <element/> // Static node
* <Comp/> // Dynamic node -> should NOT be wrapped
* <Comp/> // Dynamic node -> should be wrapped
* <Comp/> // Dynamic node -> should be wrapped
* <Comp/> // Dynamic node -> should NOT be wrapped
* <element/> // Static node
* </element>
*/
function shouldProcessChildAsDynamic(
parent: { tag?: string; children: TemplateChildNode[] },
node: TemplateChildNode & { _ssrDynamicInfo?: DynamicInfo },
): boolean {
// must be inside a parent element
if (!parent.tag) return false
// must has dynamic info
const { _ssrDynamicInfo: info } = node
if (!info) return false
const {
hasStaticPrevious,
hasStaticNext,
prevDynamicCount,
nextDynamicCount,
} = info
// must have static nodes on both sides
if (!hasStaticPrevious || !hasStaticNext) return false
const dynamicNodeCount = 1 + prevDynamicCount + nextDynamicCount
// For two consecutive dynamic nodes, mark the second one as dynamic
if (dynamicNodeCount === 2) {
return prevDynamicCount > 0
}
// For three or more dynamic nodes, mark the middle nodes as dynamic
else if (dynamicNodeCount >= 3) {
return prevDynamicCount > 0 && nextDynamicCount > 0
}
return false
}
function isFragmentChild(child: TemplateChildNode): boolean {
const { type } = child
return type === NodeTypes.IF || type === NodeTypes.FOR
}

View File

@ -55,7 +55,14 @@ import {
ssrProcessTransitionGroup, ssrProcessTransitionGroup,
ssrTransformTransitionGroup, ssrTransformTransitionGroup,
} from './ssrTransformTransitionGroup' } from './ssrTransformTransitionGroup'
import { extend, isArray, isObject, isPlainObject, isSymbol } from '@vue/shared' import {
DYNAMIC_COMPONENT_ANCHOR_LABEL,
extend,
isArray,
isObject,
isPlainObject,
isSymbol,
} from '@vue/shared'
import { buildSSRProps } from './ssrTransformElement' import { buildSSRProps } from './ssrTransformElement'
import { import {
ssrProcessTransition, ssrProcessTransition,
@ -264,6 +271,8 @@ export function ssrProcessComponent(
// dynamic component (`resolveDynamicComponent` call) // dynamic component (`resolveDynamicComponent` call)
// the codegen node is a `renderVNode` call // the codegen node is a `renderVNode` call
context.pushStatement(node.ssrCodegenNode) context.pushStatement(node.ssrCodegenNode)
// anchor for dynamic component for vapor hydration
context.pushStringPart(`<!--${DYNAMIC_COMPONENT_ANCHOR_LABEL}-->`)
} }
} }
} }

View File

@ -13,6 +13,7 @@ import {
processChildrenAsStatement, processChildrenAsStatement,
} from '../ssrCodegenTransform' } from '../ssrCodegenTransform'
import { SSR_RENDER_LIST } from '../runtimeHelpers' import { SSR_RENDER_LIST } from '../runtimeHelpers'
import { FOR_ANCHOR_LABEL } from '@vue/shared'
// Plugin for the first transform pass, which simply constructs the AST node // Plugin for the first transform pass, which simply constructs the AST node
export const ssrTransformFor: NodeTransform = export const ssrTransformFor: NodeTransform =
@ -49,4 +50,6 @@ export function ssrProcessFor(
if (!disableNestedFragments) { if (!disableNestedFragments) {
context.pushStringPart(`<!--]-->`) context.pushStringPart(`<!--]-->`)
} }
// v-for anchor for vapor hydration
context.pushStringPart(`<!--${FOR_ANCHOR_LABEL}-->`)
} }

View File

@ -14,6 +14,7 @@ import {
type SSRTransformContext, type SSRTransformContext,
processChildrenAsStatement, processChildrenAsStatement,
} from '../ssrCodegenTransform' } from '../ssrCodegenTransform'
import { IF_ANCHOR_LABEL } from '@vue/shared'
// Plugin for the first transform pass, which simply constructs the AST node // Plugin for the first transform pass, which simply constructs the AST node
export const ssrTransformIf: NodeTransform = createStructuralDirectiveTransform( export const ssrTransformIf: NodeTransform = createStructuralDirectiveTransform(
@ -74,5 +75,16 @@ function processIfBranch(
(children.length !== 1 || children[0].type !== NodeTypes.ELEMENT) && (children.length !== 1 || children[0].type !== NodeTypes.ELEMENT) &&
// optimize away nested fragments when the only child is a ForNode // optimize away nested fragments when the only child is a ForNode
!(children.length === 1 && children[0].type === NodeTypes.FOR) !(children.length === 1 && children[0].type === NodeTypes.FOR)
return processChildrenAsStatement(branch, context, needFragmentWrapper) const statement = processChildrenAsStatement(
branch,
context,
needFragmentWrapper,
)
if (branch.condition) {
// v-if/v-else-if anchor for vapor hydration
statement.body.push(
createCallExpression(`_push`, [`\`<!--${IF_ANCHOR_LABEL}-->\``]),
)
}
return statement
} }

View File

@ -0,0 +1,222 @@
import { makeCompile } from './_utils'
import {
transformChildren,
transformElement,
transformText,
transformVBind,
transformVIf,
transformVShow,
transformVSlot,
} from '@vue/compiler-vapor'
import { transformTransition } from '../../src/transforms/transformTransition'
import { DOMErrorCodes } from '@vue/compiler-dom'
const compileWithElementTransform = makeCompile({
nodeTransforms: [
transformText,
transformVIf,
transformElement,
transformVSlot,
transformChildren,
transformTransition,
],
directiveTransforms: {
bind: transformVBind,
show: transformVShow,
},
})
describe('compiler: transition', () => {
test('basic', () => {
const { code } = compileWithElementTransform(
`<Transition><h1 v-show="show">foo</h1></Transition>`,
)
expect(code).toMatchSnapshot()
})
test('v-show + appear', () => {
const { code } = compileWithElementTransform(
`<Transition appear><h1 v-show="show">foo</h1></Transition>`,
)
expect(code).toMatchSnapshot()
})
test('work with v-if', () => {
const { code } = compileWithElementTransform(
`<Transition><h1 v-if="show">foo</h1></Transition>`,
)
expect(code).toMatchSnapshot()
// n2 should have a key
expect(code).contains('n2.$key = 2')
})
test('work with dynamic keyed children', () => {
const { code } = compileWithElementTransform(
`<Transition>
<h1 :key="key">foo</h1>
</Transition>`,
)
expect(code).toMatchSnapshot()
expect(code).contains('_createKeyedFragment(() => _ctx.key')
// should preserve key
expect(code).contains('n0.$key = _ctx.key')
})
function checkWarning(template: string, shouldWarn = true) {
const onError = vi.fn()
compileWithElementTransform(template, { onError })
if (shouldWarn) {
expect(onError).toHaveBeenCalled()
expect(onError.mock.calls).toMatchObject([
[{ code: DOMErrorCodes.X_TRANSITION_INVALID_CHILDREN }],
])
} else {
expect(onError).not.toHaveBeenCalled()
}
}
test('warns if multiple children', () => {
checkWarning(
`<Transition>
<h1>foo</h1>
<h2>bar</h2>
</Transition>`,
true,
)
})
test('warns with v-for', () => {
checkWarning(
`
<transition>
<div v-for="i in items">hey</div>
</transition>
`,
true,
)
})
test('warns with multiple v-if + v-for', () => {
checkWarning(
`
<transition>
<div v-if="a" v-for="i in items">hey</div>
<div v-else v-for="i in items">hey</div>
</transition>
`,
true,
)
})
test('warns with template v-if', () => {
checkWarning(
`
<transition>
<template v-if="ok"></template>
</transition>
`,
true,
)
})
test('warns with multiple templates', () => {
checkWarning(
`
<transition>
<template v-if="a"></template>
<template v-else></template>
</transition>
`,
true,
)
})
test('warns if multiple children with v-if', () => {
checkWarning(
`
<transition>
<div v-if="one">hey</div>
<div v-if="other">hey</div>
</transition>
`,
true,
)
})
test('does not warn with regular element', () => {
checkWarning(
`
<transition>
<div>hey</div>
</transition>
`,
false,
)
})
test('does not warn with one single v-if', () => {
checkWarning(
`
<transition>
<div v-if="a">hey</div>
</transition>
`,
false,
)
})
test('does not warn with v-if v-else-if v-else', () => {
checkWarning(
`
<transition>
<div v-if="a">hey</div>
<div v-else-if="b">hey</div>
<div v-else>hey</div>
</transition>
`,
false,
)
})
test('does not warn with v-if v-else', () => {
checkWarning(
`
<transition>
<div v-if="a">hey</div>
<div v-else>hey</div>
</transition>
`,
false,
)
})
test('inject persisted when child has v-show', () => {
expect(
compileWithElementTransform(`
<Transition>
<div v-show="ok" />
</Transition>
`).code,
).toMatchSnapshot()
})
test('the v-if/else-if/else branches in Transition should ignore comments', () => {
expect(
compileWithElementTransform(`
<transition>
<div v-if="a">hey</div>
<!-- this should be ignored -->
<div v-else-if="b">hey</div>
<!-- this should be ignored -->
<div v-else>
<p v-if="c"/>
<!-- this should not be ignored -->
<p v-else/>
</div>
</transition>
`).code,
).toMatchSnapshot()
})
})

View File

@ -0,0 +1,128 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`compiler: transition > basic 1`] = `
"import { VaporTransition as _VaporTransition, applyVShow as _applyVShow, createComponent as _createComponent, template as _template } from 'vue';
const t0 = _template("<h1>foo</h1>")
export function render(_ctx) {
const n1 = _createComponent(_VaporTransition, { persisted: () => ("") }, {
"default": () => {
const n0 = t0()
_applyVShow(n0, () => (_ctx.show))
return n0
}
}, true)
return n1
}"
`;
exports[`compiler: transition > inject persisted when child has v-show 1`] = `
"import { VaporTransition as _VaporTransition, applyVShow as _applyVShow, createComponent as _createComponent, template as _template } from 'vue';
const t0 = _template("<div></div>")
export function render(_ctx) {
const n1 = _createComponent(_VaporTransition, { persisted: () => ("") }, {
"default": () => {
const n0 = t0()
_applyVShow(n0, () => (_ctx.ok))
return n0
}
}, true)
return n1
}"
`;
exports[`compiler: transition > the v-if/else-if/else branches in Transition should ignore comments 1`] = `
"import { VaporTransition as _VaporTransition, setInsertionState as _setInsertionState, createIf as _createIf, createComponent as _createComponent, template as _template } from 'vue';
const t0 = _template("<div>hey</div>")
const t1 = _template("<p></p>")
const t2 = _template("<div></div>")
export function render(_ctx) {
const n16 = _createComponent(_VaporTransition, null, {
"default": () => {
const n0 = _createIf(() => (_ctx.a), () => {
const n2 = t0()
n2.$key = 2
return n2
}, () => _createIf(() => (_ctx.b), () => {
const n5 = t0()
n5.$key = 5
return n5
}, () => {
const n14 = t2()
_setInsertionState(n14, 0)
const n9 = _createIf(() => (_ctx.c), () => {
const n11 = t1()
return n11
}, () => {
const n13 = t1()
return n13
})
n14.$key = 14
return n14
}))
return [n0, n3, n7]
}
}, true)
return n16
}"
`;
exports[`compiler: transition > v-show + appear 1`] = `
"import { VaporTransition as _VaporTransition, applyVShow as _applyVShow, createComponent as _createComponent, template as _template } from 'vue';
const t0 = _template("<h1>foo</h1>")
export function render(_ctx) {
const deferredApplyVShows = []
const n1 = _createComponent(_VaporTransition, {
appear: () => (""),
persisted: () => ("")
}, {
"default": () => {
const n0 = t0()
deferredApplyVShows.push(() => _applyVShow(n0, () => (_ctx.show)))
return n0
}
}, true)
deferredApplyVShows.forEach(fn => fn())
return n1
}"
`;
exports[`compiler: transition > work with dynamic keyed children 1`] = `
"import { VaporTransition as _VaporTransition, createKeyedFragment as _createKeyedFragment, createComponent as _createComponent, template as _template } from 'vue';
const t0 = _template("<h1>foo</h1>")
export function render(_ctx) {
const n1 = _createComponent(_VaporTransition, null, {
"default": () => {
return _createKeyedFragment(() => _ctx.key, () => {
const n0 = t0()
n0.$key = _ctx.key
return n0
})
}
}, true)
return n1
}"
`;
exports[`compiler: transition > work with v-if 1`] = `
"import { VaporTransition as _VaporTransition, createIf as _createIf, createComponent as _createComponent, template as _template } from 'vue';
const t0 = _template("<h1>foo</h1>")
export function render(_ctx) {
const n3 = _createComponent(_VaporTransition, null, {
"default": () => {
const n0 = _createIf(() => (_ctx.show), () => {
const n2 = t0()
n2.$key = 2
return n2
})
return n0
}
}, true)
return n3
}"
`;

View File

@ -63,6 +63,15 @@ export function render(_ctx) {
}" }"
`; `;
exports[`compiler: template ref transform > static ref (inline mode) 1`] = `
"
const _setTemplateRef = _createTemplateRefSetter()
const n0 = t0()
_setTemplateRef(n0, foo)
return n0
"
`;
exports[`compiler: template ref transform > static ref 1`] = ` exports[`compiler: template ref transform > static ref 1`] = `
"import { createTemplateRefSetter as _createTemplateRefSetter, template as _template } from 'vue'; "import { createTemplateRefSetter as _createTemplateRefSetter, template as _template } from 'vue';
const t0 = _template("<div></div>", true) const t0 = _template("<div></div>", true)

View File

@ -113,6 +113,35 @@ export function render(_ctx) {
}" }"
`; `;
exports[`cache multiple access > object property name substring cases 1`] = `
"import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
const t0 = _template("<div></div>", true)
export function render(_ctx) {
const n0 = t0()
_renderEffect(() => {
const _p = _ctx.p
const _p_title = _p.title
_setProp(n0, "id", _p_title + _p.titles + _p_title)
})
return n0
}"
`;
exports[`cache multiple access > optional chaining 1`] = `
"import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
const t0 = _template("<div></div>", true)
export function render(_ctx) {
const n0 = t0()
_renderEffect(() => {
const _obj = _ctx.obj
_setProp(n0, "id", _obj?.foo + _obj?.bar)
})
return n0
}"
`;
exports[`cache multiple access > repeated expression in expressions 1`] = ` exports[`cache multiple access > repeated expression in expressions 1`] = `
"import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue'; "import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
const t0 = _template("<div></div>") const t0 = _template("<div></div>")
@ -180,6 +209,20 @@ export function render(_ctx) {
}" }"
`; `;
exports[`cache multiple access > variable name substring edge cases 1`] = `
"import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
const t0 = _template("<div></div>", true)
export function render(_ctx) {
const n0 = t0()
_renderEffect(() => {
const _title = _ctx.title
_setProp(n0, "id", _title + _ctx.titles + _title)
})
return n0
}"
`;
exports[`compiler v-bind > .attr modifier 1`] = ` exports[`compiler v-bind > .attr modifier 1`] = `
"import { setAttr as _setAttr, renderEffect as _renderEffect, template as _template } from 'vue'; "import { setAttr as _setAttr, renderEffect as _renderEffect, template as _template } from 'vue';
const t0 = _template("<div></div>", true) const t0 = _template("<div></div>", true)

View File

@ -32,3 +32,13 @@ export function render(_ctx) {
return n0 return n0
}" }"
`; `;
exports[`v-html > work with dynamic component 1`] = `
"import { createDynamicComponent as _createDynamicComponent, setHtml as _setHtml, renderEffect as _renderEffect } from 'vue';
export function render(_ctx) {
const n0 = _createDynamicComponent(() => ('button'), null, null, true)
_renderEffect(() => _setHtml(n0.nodes, _ctx.foo))
return n0
}"
`;

View File

@ -103,6 +103,97 @@ export function render(_ctx) {
}" }"
`; `;
exports[`compiler: transform slot > forwarded slots > <slot w/ nested component> 1`] = `
"import { forwardedSlotCreator as _forwardedSlotCreator, resolveComponent as _resolveComponent, createComponentWithFallback as _createComponentWithFallback } from 'vue';
export function render(_ctx) {
const _createForwardedSlot = _forwardedSlotCreator()
const _component_Comp = _resolveComponent("Comp")
const n2 = _createComponentWithFallback(_component_Comp, null, {
"default": () => {
const n1 = _createComponentWithFallback(_component_Comp, null, {
"default": () => {
const n0 = _createForwardedSlot("default", null)
return n0
}
})
return n1
}
}, true)
return n2
}"
`;
exports[`compiler: transform slot > forwarded slots > <slot> tag only 1`] = `
"import { forwardedSlotCreator as _forwardedSlotCreator, resolveComponent as _resolveComponent, createComponentWithFallback as _createComponentWithFallback } from 'vue';
export function render(_ctx) {
const _createForwardedSlot = _forwardedSlotCreator()
const _component_Comp = _resolveComponent("Comp")
const n1 = _createComponentWithFallback(_component_Comp, null, {
"default": () => {
const n0 = _createForwardedSlot("default", null)
return n0
}
}, true)
return n1
}"
`;
exports[`compiler: transform slot > forwarded slots > <slot> tag w/ template 1`] = `
"import { forwardedSlotCreator as _forwardedSlotCreator, resolveComponent as _resolveComponent, createComponentWithFallback as _createComponentWithFallback } from 'vue';
export function render(_ctx) {
const _createForwardedSlot = _forwardedSlotCreator()
const _component_Comp = _resolveComponent("Comp")
const n2 = _createComponentWithFallback(_component_Comp, null, {
"default": () => {
const n0 = _createForwardedSlot("default", null)
return n0
}
}, true)
return n2
}"
`;
exports[`compiler: transform slot > forwarded slots > <slot> tag w/ v-for 1`] = `
"import { forwardedSlotCreator as _forwardedSlotCreator, resolveComponent as _resolveComponent, createFor as _createFor, createComponentWithFallback as _createComponentWithFallback } from 'vue';
export function render(_ctx) {
const _createForwardedSlot = _forwardedSlotCreator()
const _component_Comp = _resolveComponent("Comp")
const n3 = _createComponentWithFallback(_component_Comp, null, {
"default": () => {
const n0 = _createFor(() => (_ctx.b), (_for_item0) => {
const n2 = _createForwardedSlot("default", null)
return n2
})
return n0
}
}, true)
return n3
}"
`;
exports[`compiler: transform slot > forwarded slots > <slot> tag w/ v-if 1`] = `
"import { forwardedSlotCreator as _forwardedSlotCreator, resolveComponent as _resolveComponent, createIf as _createIf, createComponentWithFallback as _createComponentWithFallback } from 'vue';
export function render(_ctx) {
const _createForwardedSlot = _forwardedSlotCreator()
const _component_Comp = _resolveComponent("Comp")
const n3 = _createComponentWithFallback(_component_Comp, null, {
"default": () => {
const n0 = _createIf(() => (_ctx.ok), () => {
const n2 = _createForwardedSlot("default", null)
return n2
})
return n0
}
}, true)
return n3
}"
`;
exports[`compiler: transform slot > implicit default slot 1`] = ` exports[`compiler: transform slot > implicit default slot 1`] = `
"import { resolveComponent as _resolveComponent, createComponentWithFallback as _createComponentWithFallback, template as _template } from 'vue'; "import { resolveComponent as _resolveComponent, createComponentWithFallback as _createComponentWithFallback, template as _template } from 'vue';
const t0 = _template("<div></div>") const t0 = _template("<div></div>")

View File

@ -33,3 +33,13 @@ export function render(_ctx) {
return n0 return n0
}" }"
`; `;
exports[`v-text > work with dynamic component 1`] = `
"import { createDynamicComponent as _createDynamicComponent, toDisplayString as _toDisplayString, setElementText as _setElementText, renderEffect as _renderEffect } from 'vue';
export function render(_ctx) {
const n0 = _createDynamicComponent(() => ('button'), null, null, true)
_renderEffect(() => _setElementText(n0.nodes, _toDisplayString(_ctx.foo), true))
return n0
}"
`;

View File

@ -1,3 +1,4 @@
import { BindingTypes } from '@vue/compiler-dom'
import { import {
DynamicFlag, DynamicFlag,
type ForIRNode, type ForIRNode,
@ -48,6 +49,16 @@ describe('compiler: template ref transform', () => {
expect(code).contains('_setTemplateRef(n0, "foo")') expect(code).contains('_setTemplateRef(n0, "foo")')
}) })
test('static ref (inline mode)', () => {
const { code } = compileWithTransformRef(`<div ref="foo" />`, {
inline: true,
bindingMetadata: { foo: BindingTypes.SETUP_REF },
})
expect(code).matchSnapshot()
// pass the actual ref
expect(code).contains('_setTemplateRef(n0, foo)')
})
test('dynamic ref', () => { test('dynamic ref', () => {
const { ir, code } = compileWithTransformRef(`<div :ref="foo" />`) const { ir, code } = compileWithTransformRef(`<div :ref="foo" />`)

View File

@ -785,6 +785,25 @@ describe('cache multiple access', () => {
expect(code).contains('_setProp(n0, "id", _obj[1][_ctx.baz] + _obj.bar)') expect(code).contains('_setProp(n0, "id", _obj[1][_ctx.baz] + _obj.bar)')
}) })
test('variable name substring edge cases', () => {
const { code } = compileWithVBind(
`<div :id="title + titles + title"></div>`,
)
expect(code).matchSnapshot()
expect(code).contains('const _title = _ctx.title')
expect(code).contains('_setProp(n0, "id", _title + _ctx.titles + _title)')
})
test('object property name substring cases', () => {
const { code } = compileWithVBind(
`<div :id="p.title + p.titles + p.title"></div>`,
)
expect(code).matchSnapshot()
expect(code).contains('const _p = _ctx.p')
expect(code).contains('const _p_title = _p.title')
expect(code).contains('_setProp(n0, "id", _p_title + _p.titles + _p_title)')
})
test('cache variable used in both property shorthand and normal binding', () => { test('cache variable used in both property shorthand and normal binding', () => {
const { code } = compileWithVBind(` const { code } = compileWithVBind(`
<div :style="{color}" :id="color"/> <div :style="{color}" :id="color"/>
@ -794,6 +813,13 @@ describe('cache multiple access', () => {
expect(code).contains('_setStyle(n0, {color: _color})') expect(code).contains('_setStyle(n0, {color: _color})')
}) })
test('optional chaining', () => {
const { code } = compileWithVBind(`<div :id="obj?.foo + obj?.bar"></div>`)
expect(code).matchSnapshot()
expect(code).contains('const _obj = _ctx.obj')
expect(code).contains('_setProp(n0, "id", _obj?.foo + _obj?.bar)')
})
test('not cache variable only used in property shorthand', () => { test('not cache variable only used in property shorthand', () => {
const { code } = compileWithVBind(` const { code } = compileWithVBind(`
<div :style="{color}" /> <div :style="{color}" />

View File

@ -54,6 +54,14 @@ describe('v-html', () => {
expect(code).matchSnapshot() expect(code).matchSnapshot()
}) })
test('work with dynamic component', () => {
const { code } = compileWithVHtml(
`<component :is="'button'" v-html="foo"/>`,
)
expect(code).matchSnapshot()
expect(code).contains('setHtml(n0.nodes, _ctx.foo))')
})
test('should raise error and ignore children when v-html is present', () => { test('should raise error and ignore children when v-html is present', () => {
const onError = vi.fn() const onError = vi.fn()
const { code, ir, helpers } = compileWithVHtml( const { code, ir, helpers } = compileWithVHtml(

View File

@ -420,6 +420,35 @@ describe('compiler: transform slot', () => {
}) })
}) })
describe('forwarded slots', () => {
test('<slot> tag only', () => {
const { code } = compileWithSlots(`<Comp><slot/></Comp>`)
expect(code).toMatchSnapshot()
})
test('<slot> tag w/ v-if', () => {
const { code } = compileWithSlots(`<Comp><slot v-if="ok"/></Comp>`)
expect(code).toMatchSnapshot()
})
test('<slot> tag w/ v-for', () => {
const { code } = compileWithSlots(`<Comp><slot v-for="a in b"/></Comp>`)
expect(code).toMatchSnapshot()
})
test('<slot> tag w/ template', () => {
const { code } = compileWithSlots(
`<Comp><template #default><slot/></template></Comp>`,
)
expect(code).toMatchSnapshot()
})
test('<slot w/ nested component>', () => {
const { code } = compileWithSlots(`<Comp><Comp><slot/></Comp></Comp>`)
expect(code).toMatchSnapshot()
})
})
describe('errors', () => { describe('errors', () => {
test('error on extraneous children w/ named default slot', () => { test('error on extraneous children w/ named default slot', () => {
const onError = vi.fn() const onError = vi.fn()

View File

@ -58,6 +58,16 @@ describe('v-text', () => {
expect(code).matchSnapshot() expect(code).matchSnapshot()
}) })
test('work with dynamic component', () => {
const { code } = compileWithVText(
`<component :is="'button'" v-text="foo"/>`,
)
expect(code).matchSnapshot()
expect(code).contains(
'setElementText(n0.nodes, _toDisplayString(_ctx.foo), true)',
)
})
test('should raise error and ignore children when v-text is present', () => { test('should raise error and ignore children when v-text is present', () => {
const onError = vi.fn() const onError = vi.fn()
const { code, ir } = compileWithVText(`<div v-text="test">hello</div>`, { const { code, ir } = compileWithVText(`<div v-text="test">hello</div>`, {

View File

@ -26,6 +26,7 @@ import { transformVFor } from './transforms/vFor'
import { transformComment } from './transforms/transformComment' import { transformComment } from './transforms/transformComment'
import { transformSlotOutlet } from './transforms/transformSlotOutlet' import { transformSlotOutlet } from './transforms/transformSlotOutlet'
import { transformVSlot } from './transforms/vSlot' import { transformVSlot } from './transforms/vSlot'
import { transformTransition } from './transforms/transformTransition'
import type { HackOptions } from './ir' import type { HackOptions } from './ir'
export { wrapTemplate } from './transforms/utils' export { wrapTemplate } from './transforms/utils'
@ -54,6 +55,7 @@ export function compile(
extend({}, resolvedOptions, { extend({}, resolvedOptions, {
nodeTransforms: [ nodeTransforms: [
...nodeTransforms, ...nodeTransforms,
...(__DEV__ ? [transformTransition] : []),
...(options.nodeTransforms || []), // user transforms ...(options.nodeTransforms || []), // user transforms
], ],
directiveTransforms: extend( directiveTransforms: extend(

View File

@ -18,6 +18,7 @@ import {
genCall, genCall,
} from './generators/utils' } from './generators/utils'
import { setTemplateRefIdent } from './generators/templateRef' import { setTemplateRefIdent } from './generators/templateRef'
import { createForwardedSlotIdent } from './generators/slotOutlet'
export type CodegenOptions = Omit<BaseCodegenOptions, 'optimizeImports'> export type CodegenOptions = Omit<BaseCodegenOptions, 'optimizeImports'>
@ -129,6 +130,12 @@ export function generate(
`const ${setTemplateRefIdent} = ${context.helper('createTemplateRefSetter')}()`, `const ${setTemplateRefIdent} = ${context.helper('createTemplateRefSetter')}()`,
) )
} }
if (ir.hasForwardedSlot) {
push(
NEWLINE,
`const ${createForwardedSlotIdent} = ${context.helper('forwardedSlotCreator')}()`,
)
}
push(...genBlockContent(ir.block, context, true)) push(...genBlockContent(ir.block, context, true))
push(INDENT_END, NEWLINE) push(INDENT_END, NEWLINE)

View File

@ -13,6 +13,7 @@ import type { CodegenContext } from '../generate'
import { genEffects, genOperations } from './operation' import { genEffects, genOperations } from './operation'
import { genChildren, genSelf } from './template' import { genChildren, genSelf } from './template'
import { toValidAssetId } from '@vue/compiler-dom' import { toValidAssetId } from '@vue/compiler-dom'
import { genExpression } from './expression'
export function genBlock( export function genBlock(
oper: BlockIRNode, oper: BlockIRNode,
@ -40,9 +41,13 @@ export function genBlockContent(
customReturns?: (returns: CodeFragment[]) => CodeFragment[], customReturns?: (returns: CodeFragment[]) => CodeFragment[],
): CodeFragment[] { ): CodeFragment[] {
const [frag, push] = buildCodeFragment() const [frag, push] = buildCodeFragment()
const { dynamic, effect, operation, returns } = block const { dynamic, effect, operation, returns, key } = block
const resetBlock = context.enterBlock(block) const resetBlock = context.enterBlock(block)
if (block.hasDeferredVShow) {
push(NEWLINE, `const deferredApplyVShows = []`)
}
if (root) { if (root) {
for (let name of context.ir.component) { for (let name of context.ir.component) {
const id = toValidAssetId(name, 'component') const id = toValidAssetId(name, 'component')
@ -72,6 +77,19 @@ export function genBlockContent(
push(...genOperations(operation, context)) push(...genOperations(operation, context))
push(...genEffects(effect, context)) push(...genEffects(effect, context))
if (block.hasDeferredVShow) {
push(NEWLINE, `deferredApplyVShows.forEach(fn => fn())`)
}
if (dynamic.needsKey) {
for (const child of dynamic.children) {
const keyValue = key
? genExpression(key, context)
: JSON.stringify(child.id)
push(NEWLINE, `n${child.id}.$key = `, ...keyValue)
}
}
push(NEWLINE, `return `) push(NEWLINE, `return `)
const returnNodes = returns.map(n => `n${n}`) const returnNodes = returns.map(n => `n${n}`)

View File

@ -39,6 +39,7 @@ import { genEventHandler } from './event'
import { genDirectiveModifiers, genDirectivesForElement } from './directive' import { genDirectiveModifiers, genDirectivesForElement } from './directive'
import { genBlock } from './block' import { genBlock } from './block'
import { genModelHandler } from './vModel' import { genModelHandler } from './vModel'
import { isBuiltInComponent } from '../utils'
export function genCreateComponent( export function genCreateComponent(
operation: CreateComponentIRNode, operation: CreateComponentIRNode,
@ -47,25 +48,28 @@ export function genCreateComponent(
const { helper } = context const { helper } = context
const tag = genTag() const tag = genTag()
const { root, props, slots, once } = operation const { root, props, slots, once, scopeId } = operation
const rawSlots = genRawSlots(slots, context) const rawSlots = genRawSlots(slots, context)
const [ids, handlers] = processInlineHandlers(props, context) const [ids, handlers] = processInlineHandlers(props, context)
const rawProps = context.withId(() => genRawProps(props, context), ids) const rawProps = context.withId(() => genRawProps(props, context), ids)
const inlineHandlers: CodeFragment[] = handlers.reduce<CodeFragment[]>( const inlineHandlers: CodeFragment[] = handlers.reduce<CodeFragment[]>(
(acc, { name, value }) => { (acc, { name, value }: InlineHandler) => {
const handler = genEventHandler(context, value, undefined, false) const handler = genEventHandler(context, value, undefined, false)
return [...acc, `const ${name} = `, ...handler, NEWLINE] return [...acc, `const ${name} = `, ...handler, NEWLINE]
}, },
[], [],
) )
const isDynamicComponent = operation.dynamic && !operation.dynamic.isStatic
if (isDynamicComponent) context.block.dynamicComponents.push(operation.id)
return [ return [
NEWLINE, NEWLINE,
...inlineHandlers, ...inlineHandlers,
`const n${operation.id} = `, `const n${operation.id} = `,
...genCall( ...genCall(
operation.dynamic && !operation.dynamic.isStatic isDynamicComponent
? helper('createDynamicComponent') ? helper('createDynamicComponent')
: operation.asset : operation.asset
? helper('createComponentWithFallback') ? helper('createComponentWithFallback')
@ -75,6 +79,7 @@ export function genCreateComponent(
rawSlots, rawSlots,
root ? 'true' : false, root ? 'true' : false,
once && 'true', once && 'true',
scopeId && JSON.stringify(scopeId),
), ),
...genDirectivesForElement(operation.id, context), ...genDirectivesForElement(operation.id, context),
] ]
@ -92,8 +97,15 @@ export function genCreateComponent(
} else if (operation.asset) { } else if (operation.asset) {
return toValidAssetId(operation.tag, 'component') return toValidAssetId(operation.tag, 'component')
} else { } else {
const { tag } = operation
const builtInTag = isBuiltInComponent(tag)
if (builtInTag) {
// @ts-expect-error
helper(builtInTag)
return `_${builtInTag}`
}
return genExpression( return genExpression(
extend(createSimpleExpression(operation.tag, false), { ast: null }), extend(createSimpleExpression(tag, false), { ast: null }),
context, context,
) )
} }
@ -127,7 +139,10 @@ function processInlineHandlers(
const isMemberExp = isMemberExpression(value, context.options) const isMemberExp = isMemberExpression(value, context.options)
// cache inline handlers (fn expression or inline statement) // cache inline handlers (fn expression or inline statement)
if (!isMemberExp) { if (!isMemberExp) {
const name = getUniqueHandlerName(context, `_on_${prop.key.content}`) const name = getUniqueHandlerName(
context,
`_on_${prop.key.content.replace(/-/g, '_')}`,
)
handlers.push({ name, value }) handlers.push({ name, value })
ids[name] = null ids[name] = null
// replace the original prop value with the handler name // replace the original prop value with the handler name
@ -396,7 +411,7 @@ function genSlotBlockWithProps(oper: SlotBlockIRNode, context: CodegenContext) {
let propsName: string | undefined let propsName: string | undefined
let exitScope: (() => void) | undefined let exitScope: (() => void) | undefined
let depth: number | undefined let depth: number | undefined
const { props } = oper const { props, key } = oper
const idsOfProps = new Set<string>() const idsOfProps = new Set<string>()
if (props) { if (props) {
@ -424,11 +439,28 @@ function genSlotBlockWithProps(oper: SlotBlockIRNode, context: CodegenContext) {
? `${propsName}[${JSON.stringify(id)}]` ? `${propsName}[${JSON.stringify(id)}]`
: null), : null),
) )
const blockFn = context.withId( let blockFn = context.withId(
() => genBlock(oper, context, [propsName]), () => genBlock(oper, context, [propsName]),
idMap, idMap,
) )
exitScope && exitScope() exitScope && exitScope()
if (key) {
blockFn = [
`() => {`,
INDENT_START,
NEWLINE,
`return `,
...genCall(
context.helper('createKeyedFragment'),
[`() => `, ...genExpression(key, context)],
blockFn,
),
INDENT_END,
NEWLINE,
`}`,
]
}
return blockFn return blockFn
} }

View File

@ -283,7 +283,13 @@ export function processExpressions(
function analyzeExpressions(expressions: SimpleExpressionNode[]) { function analyzeExpressions(expressions: SimpleExpressionNode[]) {
const seenVariable: Record<string, number> = Object.create(null) const seenVariable: Record<string, number> = Object.create(null)
const variableToExpMap = new Map<string, Set<SimpleExpressionNode>>() const variableToExpMap = new Map<string, Set<SimpleExpressionNode>>()
const expToVariableMap = new Map<SimpleExpressionNode, string[]>() const expToVariableMap = new Map<
SimpleExpressionNode,
Array<{
name: string
loc?: { start: number; end: number }
}>
>()
const seenIdentifier = new Set<string>() const seenIdentifier = new Set<string>()
const updatedVariable = new Set<string>() const updatedVariable = new Set<string>()
@ -291,6 +297,7 @@ function analyzeExpressions(expressions: SimpleExpressionNode[]) {
name: string, name: string,
exp: SimpleExpressionNode, exp: SimpleExpressionNode,
isIdentifier: boolean, isIdentifier: boolean,
loc?: { start: number; end: number },
parentStack: Node[] = [], parentStack: Node[] = [],
) => { ) => {
if (isIdentifier) seenIdentifier.add(name) if (isIdentifier) seenIdentifier.add(name)
@ -299,7 +306,11 @@ function analyzeExpressions(expressions: SimpleExpressionNode[]) {
name, name,
(variableToExpMap.get(name) || new Set()).add(exp), (variableToExpMap.get(name) || new Set()).add(exp),
) )
expToVariableMap.set(exp, (expToVariableMap.get(exp) || []).concat(name))
const variables = expToVariableMap.get(exp) || []
variables.push({ name, loc })
expToVariableMap.set(exp, variables)
if ( if (
parentStack.some( parentStack.some(
p => p.type === 'UpdateExpression' || p.type === 'AssignmentExpression', p => p.type === 'UpdateExpression' || p.type === 'AssignmentExpression',
@ -317,12 +328,27 @@ function analyzeExpressions(expressions: SimpleExpressionNode[]) {
walkIdentifiers(exp.ast, (currentNode, parent, parentStack) => { walkIdentifiers(exp.ast, (currentNode, parent, parentStack) => {
if (parent && isMemberExpression(parent)) { if (parent && isMemberExpression(parent)) {
const memberExp = extractMemberExpression(parent, name => { const memberExp = extractMemberExpression(parent, id => {
registerVariable(name, exp, true) registerVariable(id.name, exp, true, {
start: id.start!,
end: id.end!,
}) })
registerVariable(memberExp, exp, false, parentStack) })
registerVariable(
memberExp,
exp,
false,
{ start: parent.start!, end: parent.end! },
parentStack,
)
} else if (!parentStack.some(isMemberExpression)) { } else if (!parentStack.some(isMemberExpression)) {
registerVariable(currentNode.name, exp, true, parentStack) registerVariable(
currentNode.name,
exp,
true,
{ start: currentNode.start!, end: currentNode.end! },
parentStack,
)
} }
}) })
} }
@ -340,11 +366,22 @@ function processRepeatedVariables(
context: CodegenContext, context: CodegenContext,
seenVariable: Record<string, number>, seenVariable: Record<string, number>,
variableToExpMap: Map<string, Set<SimpleExpressionNode>>, variableToExpMap: Map<string, Set<SimpleExpressionNode>>,
expToVariableMap: Map<SimpleExpressionNode, string[]>, expToVariableMap: Map<
SimpleExpressionNode,
Array<{ name: string; loc?: { start: number; end: number } }>
>,
seenIdentifier: Set<string>, seenIdentifier: Set<string>,
updatedVariable: Set<string>, updatedVariable: Set<string>,
): DeclarationValue[] { ): DeclarationValue[] {
const declarations: DeclarationValue[] = [] const declarations: DeclarationValue[] = []
const expToReplacementMap = new Map<
SimpleExpressionNode,
Array<{
name: string
locs: { start: number; end: number }[]
}>
>()
for (const [name, exps] of variableToExpMap) { for (const [name, exps] of variableToExpMap) {
if (updatedVariable.has(name)) continue if (updatedVariable.has(name)) continue
if (seenVariable[name] > 1 && exps.size > 0) { if (seenVariable[name] > 1 && exps.size > 0) {
@ -356,12 +393,20 @@ function processRepeatedVariables(
// e.g., foo[baz] -> foo_baz. // e.g., foo[baz] -> foo_baz.
// for identifiers, we don't need to replace the content - they will be // for identifiers, we don't need to replace the content - they will be
// replaced during context.withId(..., ids) // replaced during context.withId(..., ids)
const replaceRE = new RegExp(escapeRegExp(name), 'g')
exps.forEach(node => { exps.forEach(node => {
if (node.ast) { if (node.ast && varName !== name) {
node.content = node.content.replace(replaceRE, varName) const replacements = expToReplacementMap.get(node) || []
// re-parse the expression replacements.push({
node.ast = parseExp(context, node.content) name: varName,
locs: expToVariableMap.get(node)!.reduce(
(locs, v) => {
if (v.name === name && v.loc) locs.push(v.loc)
return locs
},
[] as { start: number; end: number }[],
),
})
expToReplacementMap.set(node, replacements)
} }
}) })
@ -384,15 +429,35 @@ function processRepeatedVariables(
} }
} }
for (const [exp, replacements] of expToReplacementMap) {
replacements
.flatMap(({ name, locs }) =>
locs.map(({ start, end }) => ({ start, end, name })),
)
.sort((a, b) => b.end - a.end)
.forEach(({ start, end, name }) => {
exp.content =
exp.content.slice(0, start - 1) + name + exp.content.slice(end - 1)
})
// re-parse the expression
exp.ast = parseExp(context, exp.content)
}
return declarations return declarations
} }
function shouldDeclareVariable( function shouldDeclareVariable(
name: string, name: string,
expToVariableMap: Map<SimpleExpressionNode, string[]>, expToVariableMap: Map<
SimpleExpressionNode,
Array<{ name: string; loc?: { start: number; end: number } }>
>,
exps: Set<SimpleExpressionNode>, exps: Set<SimpleExpressionNode>,
): boolean { ): boolean {
const vars = Array.from(exps, exp => expToVariableMap.get(exp)!) const vars = Array.from(exps, exp =>
expToVariableMap.get(exp)!.map(v => v.name),
)
// assume name equals to `foo` // assume name equals to `foo`
// if each expression only references `foo`, declaration is needed // if each expression only references `foo`, declaration is needed
// to avoid reactivity tracking // to avoid reactivity tracking
@ -439,12 +504,15 @@ function processRepeatedExpressions(
expressions: SimpleExpressionNode[], expressions: SimpleExpressionNode[],
varDeclarations: DeclarationValue[], varDeclarations: DeclarationValue[],
updatedVariable: Set<string>, updatedVariable: Set<string>,
expToVariableMap: Map<SimpleExpressionNode, string[]>, expToVariableMap: Map<
SimpleExpressionNode,
Array<{ name: string; loc?: { start: number; end: number } }>
>,
): DeclarationValue[] { ): DeclarationValue[] {
const declarations: DeclarationValue[] = [] const declarations: DeclarationValue[] = []
const seenExp = expressions.reduce( const seenExp = expressions.reduce(
(acc, exp) => { (acc, exp) => {
const variables = expToVariableMap.get(exp) const variables = expToVariableMap.get(exp)!.map(v => v.name)
// only handle expressions that are not identifiers // only handle expressions that are not identifiers
if ( if (
exp.ast && exp.ast &&
@ -572,12 +640,12 @@ function genVarName(exp: string): string {
function extractMemberExpression( function extractMemberExpression(
exp: Node, exp: Node,
onIdentifier: (name: string) => void, onIdentifier: (id: Identifier) => void,
): string { ): string {
if (!exp) return '' if (!exp) return ''
switch (exp.type) { switch (exp.type) {
case 'Identifier': // foo[bar] case 'Identifier': // foo[bar]
onIdentifier(exp.name) onIdentifier(exp)
return exp.name return exp.name
case 'StringLiteral': // foo['bar'] case 'StringLiteral': // foo['bar']
return exp.extra ? (exp.extra.raw as string) : exp.value return exp.extra ? (exp.extra.raw as string) : exp.value
@ -588,6 +656,7 @@ function extractMemberExpression(
case 'CallExpression': // foo[bar(baz)] case 'CallExpression': // foo[bar(baz)]
return `${extractMemberExpression(exp.callee, onIdentifier)}(${exp.arguments.map(arg => extractMemberExpression(arg, onIdentifier)).join(', ')})` return `${extractMemberExpression(exp.callee, onIdentifier)}(${exp.arguments.map(arg => extractMemberExpression(arg, onIdentifier)).join(', ')})`
case 'MemberExpression': // foo[bar.baz] case 'MemberExpression': // foo[bar.baz]
case 'OptionalMemberExpression': // foo?.bar
const object = extractMemberExpression(exp.object, onIdentifier) const object = extractMemberExpression(exp.object, onIdentifier)
const prop = exp.computed const prop = exp.computed
? `[${extractMemberExpression(exp.property, onIdentifier)}]` ? `[${extractMemberExpression(exp.property, onIdentifier)}]`

View File

@ -7,10 +7,21 @@ export function genSetHtml(
oper: SetHtmlIRNode, oper: SetHtmlIRNode,
context: CodegenContext, context: CodegenContext,
): CodeFragment[] { ): CodeFragment[] {
const { helper } = context const {
helper,
block: { dynamicComponents },
} = context
const isDynamicComponent = dynamicComponents.includes(oper.element)
const { value, element } = oper const { value, element } = oper
return [ return [
NEWLINE, NEWLINE,
...genCall(helper('setHtml'), `n${element}`, genExpression(value, context)), ...genCall(
helper('setHtml'),
// if the element is a dynamic component (VaporFragment)
// it should set html to the VaporFragment's nodes
`n${element}${isDynamicComponent ? '.nodes' : ''}`,
genExpression(value, context),
),
] ]
} }

View File

@ -5,12 +5,14 @@ import { genExpression } from './expression'
import { type CodeFragment, NEWLINE, buildCodeFragment, genCall } from './utils' import { type CodeFragment, NEWLINE, buildCodeFragment, genCall } from './utils'
import { genRawProps } from './component' import { genRawProps } from './component'
export const createForwardedSlotIdent = `_createForwardedSlot`
export function genSlotOutlet( export function genSlotOutlet(
oper: SlotOutletIRNode, oper: SlotOutletIRNode,
context: CodegenContext, context: CodegenContext,
): CodeFragment[] { ): CodeFragment[] {
const { helper } = context const { helper } = context
const { id, name, fallback } = oper const { id, name, fallback, forwarded } = oper
const [frag, push] = buildCodeFragment() const [frag, push] = buildCodeFragment()
const nameExpr = name.isStatic const nameExpr = name.isStatic
@ -26,7 +28,7 @@ export function genSlotOutlet(
NEWLINE, NEWLINE,
`const n${id} = `, `const n${id} = `,
...genCall( ...genCall(
helper('createSlot'), forwarded ? createForwardedSlotIdent : helper('createSlot'),
nameExpr, nameExpr,
genRawProps(oper.props, context) || 'null', genRawProps(oper.props, context) || 'null',
fallbackArg, fallbackArg,

View File

@ -24,10 +24,10 @@ export function genSelf(
context: CodegenContext, context: CodegenContext,
): CodeFragment[] { ): CodeFragment[] {
const [frag, push] = buildCodeFragment() const [frag, push] = buildCodeFragment()
const { id, template, operation } = dynamic const { id, template, operation, dynamicChildOffset } = dynamic
if (id !== undefined && template !== undefined) { if (id !== undefined && template !== undefined) {
push(NEWLINE, `const n${id} = t${template}()`) push(NEWLINE, `const n${id} = t${template}(${dynamicChildOffset || ''})`)
push(...genDirectivesForElement(id, context)) push(...genDirectivesForElement(id, context))
} }

View File

@ -2,6 +2,7 @@ import { genExpression } from './expression'
import type { CodegenContext } from '../generate' import type { CodegenContext } from '../generate'
import type { DeclareOldRefIRNode, SetTemplateRefIRNode } from '../ir' import type { DeclareOldRefIRNode, SetTemplateRefIRNode } from '../ir'
import { type CodeFragment, NEWLINE, genCall } from './utils' import { type CodeFragment, NEWLINE, genCall } from './utils'
import { BindingTypes, type SimpleExpressionNode } from '@vue/compiler-dom'
export const setTemplateRefIdent = `_setTemplateRef` export const setTemplateRefIdent = `_setTemplateRef`
@ -15,7 +16,7 @@ export function genSetTemplateRef(
...genCall( ...genCall(
setTemplateRefIdent, // will be generated in root scope setTemplateRefIdent, // will be generated in root scope
`n${oper.element}`, `n${oper.element}`,
genExpression(oper.value, context), genRefValue(oper.value, context),
oper.effect ? `r${oper.element}` : oper.refFor ? 'void 0' : undefined, oper.effect ? `r${oper.element}` : oper.refFor ? 'void 0' : undefined,
oper.refFor && 'true', oper.refFor && 'true',
), ),
@ -25,3 +26,20 @@ export function genSetTemplateRef(
export function genDeclareOldRef(oper: DeclareOldRefIRNode): CodeFragment[] { export function genDeclareOldRef(oper: DeclareOldRefIRNode): CodeFragment[] {
return [NEWLINE, `let r${oper.id}`] return [NEWLINE, `let r${oper.id}`]
} }
function genRefValue(value: SimpleExpressionNode, context: CodegenContext) {
// in inline mode there is no setupState object, so we can't use string
// keys to set the ref. Instead, we need to transform it to pass the
// actual ref instead.
if (!__BROWSER__ && value && context.options.inline) {
const binding = context.options.bindingMetadata[value.content]
if (
binding === BindingTypes.SETUP_LET ||
binding === BindingTypes.SETUP_REF ||
binding === BindingTypes.SETUP_MAYBE_REF
) {
return [value.content]
}
}
return genExpression(value, context)
}

View File

@ -9,12 +9,32 @@ export function genSetText(
oper: SetTextIRNode, oper: SetTextIRNode,
context: CodegenContext, context: CodegenContext,
): CodeFragment[] { ): CodeFragment[] {
const { helper } = context const {
helper,
block: { dynamicComponents },
} = context
const { element, values, generated, jsx } = oper const { element, values, generated, jsx } = oper
const texts = combineValues(values, context, jsx) const texts = combineValues(values, context, jsx)
return [
// if the element is a dynamic component, we need to use `setElementText`
// to set the textContent of the VaporFragment's nodes.
return dynamicComponents.includes(oper.element)
? [
NEWLINE, NEWLINE,
...genCall(helper('setText'), `${generated ? 'x' : 'n'}${element}`, texts), ...genCall(
helper('setElementText'),
`n${element}.nodes`,
texts,
'true', // isConverted
),
]
: [
NEWLINE,
...genCall(
helper('setText'),
`${generated ? 'x' : 'n'}${element}`,
texts,
),
] ]
} }
@ -40,6 +60,14 @@ export function genGetTextChild(
oper: GetTextChildIRNode, oper: GetTextChildIRNode,
context: CodegenContext, context: CodegenContext,
): CodeFragment[] { ): CodeFragment[] {
const {
block: { dynamicComponents },
} = context
// if the parent is a dynamic component, don't need to generate a child
// because it will use the `setElementText` helper directly.
if (dynamicComponents.includes(oper.parent)) return []
return [ return [
NEWLINE, NEWLINE,
`const x${oper.parent} = ${context.helper('child')}(n${oper.parent})`, `const x${oper.parent} = ${context.helper('child')}(n${oper.parent})`,

View File

@ -7,12 +7,15 @@ export function genVShow(
oper: DirectiveIRNode, oper: DirectiveIRNode,
context: CodegenContext, context: CodegenContext,
): CodeFragment[] { ): CodeFragment[] {
const { deferred, element } = oper
return [ return [
NEWLINE, NEWLINE,
...genCall(context.helper('applyVShow'), `n${oper.element}`, [ deferred ? `deferredApplyVShows.push(() => ` : undefined,
...genCall(context.helper('applyVShow'), `n${element}`, [
`() => (`, `() => (`,
...genExpression(oper.dir.exp!, context), ...genExpression(oper.dir.exp!, context),
`)`, `)`,
]), ]),
deferred ? `)` : undefined,
] ]
} }

View File

@ -39,6 +39,7 @@ export enum IRNodeTypes {
export interface BaseIRNode { export interface BaseIRNode {
type: IRNodeTypes type: IRNodeTypes
key?: SimpleExpressionNode | undefined
} }
export type CoreHelper = keyof typeof import('packages/runtime-dom/src') export type CoreHelper = keyof typeof import('packages/runtime-dom/src')
@ -49,10 +50,12 @@ export interface BlockIRNode extends BaseIRNode {
type: IRNodeTypes.BLOCK type: IRNodeTypes.BLOCK
node: RootNode | TemplateChildNode node: RootNode | TemplateChildNode
dynamic: IRDynamicInfo dynamic: IRDynamicInfo
dynamicComponents: number[]
tempId: number tempId: number
effect: IREffect[] effect: IREffect[]
operation: OperationNode[] operation: OperationNode[]
returns: number[] returns: number[]
hasDeferredVShow: boolean
} }
export interface RootIRNode { export interface RootIRNode {
@ -65,6 +68,7 @@ export interface RootIRNode {
directive: Set<string> directive: Set<string>
block: BlockIRNode block: BlockIRNode
hasTemplateRef: boolean hasTemplateRef: boolean
hasForwardedSlot: boolean
} }
export interface IfIRNode extends BaseIRNode { export interface IfIRNode extends BaseIRNode {
@ -181,6 +185,7 @@ export interface DirectiveIRNode extends BaseIRNode {
builtin?: boolean builtin?: boolean
asset?: boolean asset?: boolean
modelType?: 'text' | 'dynamic' | 'radio' | 'checkbox' | 'select' modelType?: 'text' | 'dynamic' | 'radio' | 'checkbox' | 'select'
deferred?: boolean
} }
export interface CreateComponentIRNode extends BaseIRNode { export interface CreateComponentIRNode extends BaseIRNode {
@ -195,6 +200,7 @@ export interface CreateComponentIRNode extends BaseIRNode {
dynamic?: SimpleExpressionNode dynamic?: SimpleExpressionNode
parent?: number parent?: number
anchor?: number anchor?: number
scopeId?: string | null
} }
export interface DeclareOldRefIRNode extends BaseIRNode { export interface DeclareOldRefIRNode extends BaseIRNode {
@ -208,6 +214,7 @@ export interface SlotOutletIRNode extends BaseIRNode {
name: SimpleExpressionNode name: SimpleExpressionNode
props: IRProps[] props: IRProps[]
fallback?: BlockIRNode fallback?: BlockIRNode
forwarded?: boolean
parent?: number parent?: number
anchor?: number anchor?: number
} }
@ -259,7 +266,9 @@ export interface IRDynamicInfo {
children: IRDynamicInfo[] children: IRDynamicInfo[]
template?: number template?: number
hasDynamicChild?: boolean hasDynamicChild?: boolean
dynamicChildOffset?: number
operation?: OperationNode operation?: OperationNode
needsKey?: boolean
} }
export interface IREffect { export interface IREffect {

View File

@ -78,6 +78,7 @@ export class TransformContext<T extends AllNode = AllNode> {
inVOnce: boolean = false inVOnce: boolean = false
inVFor: number = 0 inVFor: number = 0
inSlot: boolean = false
comment: CommentNode[] = [] comment: CommentNode[] = []
component: Set<string> = this.ir.component component: Set<string> = this.ir.component
@ -219,6 +220,7 @@ export function transform(
directive: new Set(), directive: new Set(),
block: newBlock(node), block: newBlock(node),
hasTemplateRef: false, hasTemplateRef: false,
hasForwardedSlot: false,
} }
const context = new TransformContext(ir, node, options) const context = new TransformContext(ir, node, options)

View File

@ -59,7 +59,7 @@ export const transformChildren: NodeTransform = (node, context) => {
function processDynamicChildren(context: TransformContext<ElementNode>) { function processDynamicChildren(context: TransformContext<ElementNode>) {
let prevDynamics: IRDynamicInfo[] = [] let prevDynamics: IRDynamicInfo[] = []
let hasStaticTemplate = false let staticCount = 0
const children = context.dynamic.children const children = context.dynamic.children
for (const [index, child] of children.entries()) { for (const [index, child] of children.entries()) {
@ -69,22 +69,36 @@ function processDynamicChildren(context: TransformContext<ElementNode>) {
if (!(child.flags & DynamicFlag.NON_TEMPLATE)) { if (!(child.flags & DynamicFlag.NON_TEMPLATE)) {
if (prevDynamics.length) { if (prevDynamics.length) {
if (hasStaticTemplate) { if (staticCount) {
context.childrenTemplate[index - prevDynamics.length] = `<!>` // each dynamic child gets its own placeholder node.
prevDynamics[0].flags -= DynamicFlag.NON_TEMPLATE // this makes it easier to locate the corresponding node during hydration.
const anchor = (prevDynamics[0].anchor = context.increaseId()) for (let i = 0; i < prevDynamics.length; i++) {
registerInsertion(prevDynamics, context, anchor) const idx = index - prevDynamics.length + i
context.childrenTemplate[idx] = `<!>`
const dynamicChild = prevDynamics[i]
dynamicChild.flags -= DynamicFlag.NON_TEMPLATE
const anchor = (dynamicChild.anchor = context.increaseId())
if (
dynamicChild.operation &&
isBlockOperation(dynamicChild.operation)
) {
// block types
dynamicChild.operation.parent = context.reference()
dynamicChild.operation.anchor = anchor
}
}
} else { } else {
registerInsertion(prevDynamics, context, -1 /* prepend */) registerInsertion(prevDynamics, context, -1 /* prepend */)
} }
prevDynamics = [] prevDynamics = []
} }
hasStaticTemplate = true staticCount++
} }
} }
if (prevDynamics.length) { if (prevDynamics.length) {
registerInsertion(prevDynamics, context) registerInsertion(prevDynamics, context, undefined)
context.dynamic.dynamicChildOffset = staticCount
} }
} }

View File

@ -1,4 +1,3 @@
import { isValidHTMLNesting } from '@vue/compiler-dom'
import { import {
type AttributeNode, type AttributeNode,
type ComponentNode, type ComponentNode,
@ -11,6 +10,7 @@ import {
createCompilerError, createCompilerError,
createSimpleExpression, createSimpleExpression,
isStaticArgOf, isStaticArgOf,
isValidHTMLNesting,
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import { import {
camelize, camelize,
@ -36,7 +36,7 @@ import {
type VaporDirectiveNode, type VaporDirectiveNode,
} from '../ir' } from '../ir'
import { EMPTY_EXPRESSION } from './utils' import { EMPTY_EXPRESSION } from './utils'
import { findProp } from '../utils' import { findProp, isBuiltInComponent } from '../utils'
export const isReservedProp: (key: string) => boolean = /*#__PURE__*/ makeMap( export const isReservedProp: (key: string) => boolean = /*#__PURE__*/ makeMap(
// the leading comma is intentional so empty string "" is also included // the leading comma is intentional so empty string "" is also included
@ -122,6 +122,12 @@ function transformComponentElement(
asset = false asset = false
} }
const builtInTag = isBuiltInComponent(tag)
if (builtInTag) {
tag = builtInTag
asset = false
}
const dotIndex = tag.indexOf('.') const dotIndex = tag.indexOf('.')
if (dotIndex > 0) { if (dotIndex > 0) {
const ns = resolveSetupReference(tag.slice(0, dotIndex), context) const ns = resolveSetupReference(tag.slice(0, dotIndex), context)
@ -153,6 +159,7 @@ function transformComponentElement(
root: singleRoot && !context.inVFor, root: singleRoot && !context.inVFor,
slots: [...context.slots], slots: [...context.slots],
once: context.inVOnce, once: context.inVOnce,
scopeId: context.inSlot ? context.options.scopeId : undefined,
dynamic: dynamicComponent, dynamic: dynamicComponent,
} }
context.slots = [] context.slots = []
@ -437,7 +444,9 @@ function dedupeProperties(results: DirectiveTransformResult[]): IRProp[] {
} }
const name = prop.key.content const name = prop.key.content
const existing = knownProps.get(name) const existing = knownProps.get(name)
if (existing) { // prop names and event handler names can be the same but serve different purposes
// e.g. `:appear="true"` is a prop while `@appear="handler"` is an event handler
if (existing && existing.handler === prop.handler) {
if (name === 'style' || name === 'class') { if (name === 'style' || name === 'class') {
mergePropValues(existing, prop) mergePropValues(existing, prop)
} }

View File

@ -99,6 +99,7 @@ export const transformSlotOutlet: NodeTransform = (node, context) => {
} }
return () => { return () => {
if (context.inSlot) context.ir.hasForwardedSlot = true
exitBlock && exitBlock() exitBlock && exitBlock()
context.dynamic.operation = { context.dynamic.operation = {
type: IRNodeTypes.SLOT_OUTLET_NODE, type: IRNodeTypes.SLOT_OUTLET_NODE,
@ -106,6 +107,7 @@ export const transformSlotOutlet: NodeTransform = (node, context) => {
name: slotName, name: slotName,
props: irProps, props: irProps,
fallback, fallback,
forwarded: context.inSlot,
} }
} }
} }

View File

@ -0,0 +1,65 @@
import type { NodeTransform } from '@vue/compiler-vapor'
import { findDir, isTransitionTag } from '../utils'
import {
type ElementNode,
ElementTypes,
NodeTypes,
isTemplateNode,
postTransformTransition,
} from '@vue/compiler-dom'
export const transformTransition: NodeTransform = (node, context) => {
if (
node.type === NodeTypes.ELEMENT &&
node.tagType === ElementTypes.COMPONENT
) {
if (isTransitionTag(node.tag)) {
return postTransformTransition(
node,
context.options.onError,
hasMultipleChildren,
)
}
}
}
function hasMultipleChildren(node: ElementNode): boolean {
const children = (node.children = node.children.filter(
c =>
c.type !== NodeTypes.COMMENT &&
!(c.type === NodeTypes.TEXT && !c.content.trim()),
))
const first = children[0]
// has v-for
if (
children.length === 1 &&
first.type === NodeTypes.ELEMENT &&
(findDir(first, 'for') || isTemplateNode(first))
) {
return true
}
const hasElse = (node: ElementNode) =>
findDir(node, 'else-if') || findDir(node, 'else', true)
// has v-if/v-else-if/v-else
if (
children.every(
(c, index) =>
c.type === NodeTypes.ELEMENT &&
// not template
!isTemplateNode(c) &&
// not has v-for
!findDir(c, 'for') &&
// if the first child has v-if, the rest should also have v-else-if/v-else
(index === 0 ? findDir(c, 'if') : hasElse(c)) &&
!hasMultipleChildren(c),
)
) {
return false
}
return children.length > 1
}

View File

@ -26,10 +26,12 @@ export const newBlock = (node: BlockIRNode['node']): BlockIRNode => ({
type: IRNodeTypes.BLOCK, type: IRNodeTypes.BLOCK,
node, node,
dynamic: newDynamic(), dynamic: newDynamic(),
dynamicComponents: [],
effect: [], effect: [],
operation: [], operation: [],
returns: [], returns: [],
tempId: 0, tempId: 0,
hasDeferredVShow: false,
}) })
export function wrapTemplate(node: ElementNode, dirs: string[]): TemplateNode { export function wrapTemplate(node: ElementNode, dirs: string[]): TemplateNode {

View File

@ -18,7 +18,7 @@ import {
import { extend } from '@vue/shared' import { extend } from '@vue/shared'
import { newBlock, wrapTemplate } from './utils' import { newBlock, wrapTemplate } from './utils'
import { getSiblingIf } from './transformComment' import { getSiblingIf } from './transformComment'
import { isStaticExpression } from '../utils' import { isInTransition, isStaticExpression } from '../utils'
export const transformVIf: NodeTransform = createStructuralDirectiveTransform( export const transformVIf: NodeTransform = createStructuralDirectiveTransform(
['if', 'else', 'else-if'], ['if', 'else', 'else-if'],
@ -135,5 +135,8 @@ export function createIfBranch(
const branch: BlockIRNode = newBlock(node) const branch: BlockIRNode = newBlock(node)
const exitBlock = context.enterBlock(branch) const exitBlock = context.enterBlock(branch)
context.reference() context.reference()
// generate key for branch result when it's in transition
// the key will be used to track node leaving at runtime
branch.dynamic.needsKey = isInTransition(context)
return [branch, exitBlock] return [branch, exitBlock]
} }

View File

@ -2,11 +2,13 @@ import {
DOMErrorCodes, DOMErrorCodes,
ElementTypes, ElementTypes,
ErrorCodes, ErrorCodes,
NodeTypes,
createCompilerError, createCompilerError,
createDOMCompilerError, createDOMCompilerError,
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import type { DirectiveTransform } from '../transform' import type { DirectiveTransform } from '../transform'
import { IRNodeTypes } from '../ir' import { IRNodeTypes } from '../ir'
import { findProp, isTransitionTag } from '../utils'
export const transformVShow: DirectiveTransform = (dir, node, context) => { export const transformVShow: DirectiveTransform = (dir, node, context) => {
const { exp, loc } = dir const { exp, loc } = dir
@ -27,11 +29,26 @@ export const transformVShow: DirectiveTransform = (dir, node, context) => {
return return
} }
// lazy apply vshow if the node is inside a transition with appear
let shouldDeferred = false
const parentNode = context.parent && context.parent.node
if (parentNode && parentNode.type === NodeTypes.ELEMENT) {
shouldDeferred = !!(
isTransitionTag(parentNode.tag) &&
findProp(parentNode, 'appear', false, true)
)
if (shouldDeferred) {
context.parent!.parent!.block.hasDeferredVShow = true
}
}
context.registerOperation({ context.registerOperation({
type: IRNodeTypes.DIRECTIVE, type: IRNodeTypes.DIRECTIVE,
element: context.reference(), element: context.reference(),
dir, dir,
name: 'show', name: 'show',
builtin: true, builtin: true,
deferred: shouldDeferred,
}) })
} }

View File

@ -23,7 +23,12 @@ import {
type SlotBlockIRNode, type SlotBlockIRNode,
type VaporDirectiveNode, type VaporDirectiveNode,
} from '../ir' } from '../ir'
import { findDir, resolveExpression } from '../utils' import {
findDir,
findProp,
isTransitionNode,
resolveExpression,
} from '../utils'
import { markNonTemplate } from './transformText' import { markNonTemplate } from './transformText'
export const transformVSlot: NodeTransform = (node, context) => { export const transformVSlot: NodeTransform = (node, context) => {
@ -67,7 +72,6 @@ function transformComponentSlot(
) { ) {
const { children } = node const { children } = node
const arg = dir && dir.arg const arg = dir && dir.arg
// whitespace: 'preserve' // whitespace: 'preserve'
const emptyTextNodes: TemplateChildNode[] = [] const emptyTextNodes: TemplateChildNode[] = []
const nonSlotTemplateChildren = children.filter(n => { const nonSlotTemplateChildren = children.filter(n => {
@ -83,7 +87,18 @@ function transformComponentSlot(
}) })
} }
const [block, onExit] = createSlotBlock(node, dir, context) let slotKey
if (isTransitionNode(node) && nonSlotTemplateChildren.length) {
const keyProp = findProp(
nonSlotTemplateChildren[0] as ElementNode,
'key',
) as VaporDirectiveNode
if (keyProp) {
slotKey = keyProp.exp
}
}
const [block, onExit] = createSlotBlock(node, dir, context, slotKey)
const { slots } = context const { slots } = context
@ -244,11 +259,23 @@ function createSlotBlock(
slotNode: ElementNode, slotNode: ElementNode,
dir: VaporDirectiveNode | undefined, dir: VaporDirectiveNode | undefined,
context: TransformContext<ElementNode>, context: TransformContext<ElementNode>,
key: SimpleExpressionNode | undefined = undefined,
): [SlotBlockIRNode, () => void] { ): [SlotBlockIRNode, () => void] {
const block: SlotBlockIRNode = newBlock(slotNode) const block: SlotBlockIRNode = newBlock(slotNode)
block.props = dir && dir.exp block.props = dir && dir.exp
if (key) {
block.key = key
block.dynamic.needsKey = true
}
const exitBlock = context.enterBlock(block) const exitBlock = context.enterBlock(block)
return [block, exitBlock] context.inSlot = true
return [
block,
() => {
context.inSlot = false
exitBlock()
},
]
} }
function isNonWhitespaceContent(node: TemplateChildNode): boolean { function isNonWhitespaceContent(node: TemplateChildNode): boolean {

View File

@ -15,6 +15,7 @@ import {
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import type { VaporDirectiveNode } from './ir' import type { VaporDirectiveNode } from './ir'
import { EMPTY_EXPRESSION } from './transforms/utils' import { EMPTY_EXPRESSION } from './transforms/utils'
import type { TransformContext } from './transform'
export const findProp = _findProp as ( export const findProp = _findProp as (
node: ElementNode, node: ElementNode,
@ -88,3 +89,43 @@ export function getLiteralExpressionValue(
} }
return exp.isStatic ? exp.content : null return exp.isStatic ? exp.content : null
} }
export function isInTransition(
context: TransformContext<ElementNode>,
): boolean {
const parentNode = context.parent && context.parent.node
return !!(parentNode && isTransitionNode(parentNode as ElementNode))
}
export function isTransitionNode(node: ElementNode): boolean {
return node.type === NodeTypes.ELEMENT && isTransitionTag(node.tag)
}
export function isTransitionGroupNode(node: ElementNode): boolean {
return node.type === NodeTypes.ELEMENT && isTransitionGroupTag(node.tag)
}
export function isTransitionTag(tag: string): boolean {
tag = tag.toLowerCase()
return tag === 'transition' || tag === 'vaportransition'
}
export function isTransitionGroupTag(tag: string): boolean {
tag = tag.toLowerCase().replace(/-/g, '')
return tag === 'transitiongroup' || tag === 'vaportransitiongroup'
}
export function isTeleportTag(tag: string): boolean {
tag = tag.toLowerCase()
return tag === 'teleport' || tag === 'vaporteleport'
}
export function isBuiltInComponent(tag: string): string | undefined {
if (isTransitionTag(tag)) {
return 'VaporTransition'
} else if (isTransitionGroupTag(tag)) {
return 'VaporTransitionGroup'
} else if (isTeleportTag(tag)) {
return 'VaporTeleport'
}
}

View File

@ -601,14 +601,14 @@ describe('SSR hydration', () => {
const ctx: SSRContext = {} const ctx: SSRContext = {}
container.innerHTML = await renderToString(h(App), ctx) container.innerHTML = await renderToString(h(App), ctx)
expect(container.innerHTML).toBe( expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>', '<div><!--teleport start--><!--teleport end--><!--if--></div>',
) )
teleportContainer.innerHTML = ctx.teleports!['#target'] teleportContainer.innerHTML = ctx.teleports!['#target']
// hydrate // hydrate
createSSRApp(App).mount(container) createSSRApp(App).mount(container)
expect(container.innerHTML).toBe( expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>', '<div><!--teleport start--><!--teleport end--><!--if--></div>',
) )
expect(teleportContainer.innerHTML).toBe( expect(teleportContainer.innerHTML).toBe(
'<!--teleport start anchor--><span>Teleported Comp1</span><!--teleport anchor-->', '<!--teleport start anchor--><span>Teleported Comp1</span><!--teleport anchor-->',
@ -617,7 +617,7 @@ describe('SSR hydration', () => {
toggle.value = false toggle.value = false
await nextTick() await nextTick()
expect(container.innerHTML).toBe('<div><div>Comp2</div></div>') expect(container.innerHTML).toBe('<div><div>Comp2</div><!--if--></div>')
expect(teleportContainer.innerHTML).toBe('') expect(teleportContainer.innerHTML).toBe('')
}) })
@ -660,21 +660,21 @@ describe('SSR hydration', () => {
// server render // server render
container.innerHTML = await renderToString(h(App)) container.innerHTML = await renderToString(h(App))
expect(container.innerHTML).toBe( expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>', '<div><!--teleport start--><!--teleport end--><!--if--></div>',
) )
expect(teleportContainer.innerHTML).toBe('') expect(teleportContainer.innerHTML).toBe('')
// hydrate // hydrate
createSSRApp(App).mount(container) createSSRApp(App).mount(container)
expect(container.innerHTML).toBe( expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>', '<div><!--teleport start--><!--teleport end--><!--if--></div>',
) )
expect(teleportContainer.innerHTML).toBe('<span>Teleported Comp1</span>') expect(teleportContainer.innerHTML).toBe('<span>Teleported Comp1</span>')
expect(`Hydration children mismatch`).toHaveBeenWarned() expect(`Hydration children mismatch`).toHaveBeenWarned()
toggle.value = false toggle.value = false
await nextTick() await nextTick()
expect(container.innerHTML).toBe('<div><div>Comp2</div></div>') expect(container.innerHTML).toBe('<div><div>Comp2</div><!--if--></div>')
expect(teleportContainer.innerHTML).toBe('') expect(teleportContainer.innerHTML).toBe('')
}) })
@ -1846,6 +1846,36 @@ describe('SSR hydration', () => {
} }
}) })
describe('dynamic anchor', () => {
test('two consecutive components', () => {
const Comp = {
render() {
return createTextVNode('foo')
},
}
const { vnode, container } = mountWithHydration(
`<div><span></span>foo<!--[[-->foo<!--]]--><span></span></div>`,
() => h('div', null, [h('span'), h(Comp), h(Comp), h('span')]),
)
expect(vnode.el).toBe(container.firstChild)
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
})
test('multiple consecutive components', () => {
const Comp = {
render() {
return createTextVNode('foo')
},
}
const { vnode, container } = mountWithHydration(
`<div><span></span>foo<!--[[-->foo<!--]]-->foo<span></span></div>`,
() => h('div', null, [h('span'), h(Comp), h(Comp), h(Comp), h('span')]),
)
expect(vnode.el).toBe(container.firstChild)
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
})
})
test('hmr reload child wrapped in KeepAlive', async () => { test('hmr reload child wrapped in KeepAlive', async () => {
const id = 'child-reload' const id = 'child-reload'
const Child = { const Child = {

View File

@ -12,7 +12,7 @@ import type { ComponentPublicInstance } from './componentPublicInstance'
import { type VNode, createVNode } from './vnode' import { type VNode, createVNode } from './vnode'
import { defineComponent } from './apiDefineComponent' import { defineComponent } from './apiDefineComponent'
import { warn } from './warning' import { warn } from './warning'
import { ref } from '@vue/reactivity' import { type Ref, ref } from '@vue/reactivity'
import { ErrorCodes, handleError } from './errorHandling' import { ErrorCodes, handleError } from './errorHandling'
import { isKeepAlive } from './components/KeepAlive' import { isKeepAlive } from './components/KeepAlive'
import { markAsyncBoundary } from './helpers/useId' import { markAsyncBoundary } from './helpers/useId'
@ -24,10 +24,10 @@ export type AsyncComponentLoader<T = any> = () => Promise<
AsyncComponentResolveResult<T> AsyncComponentResolveResult<T>
> >
export interface AsyncComponentOptions<T = any> { export interface AsyncComponentOptions<T = any, C = any> {
loader: AsyncComponentLoader<T> loader: AsyncComponentLoader<T>
loadingComponent?: Component loadingComponent?: C
errorComponent?: Component errorComponent?: C
delay?: number delay?: number
timeout?: number timeout?: number
suspensible?: boolean suspensible?: boolean
@ -46,75 +46,20 @@ export const isAsyncWrapper = (i: GenericComponentInstance | VNode): boolean =>
/*! #__NO_SIDE_EFFECTS__ */ /*! #__NO_SIDE_EFFECTS__ */
export function defineAsyncComponent< export function defineAsyncComponent<
T extends Component = { new (): ComponentPublicInstance }, T extends Component = { new (): ComponentPublicInstance },
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T { >(source: AsyncComponentLoader<T> | AsyncComponentOptions<T, Component>): T {
if (isFunction(source)) {
source = { loader: source }
}
const { const {
loader, load,
getResolvedComp,
setPendingRequest,
source: {
loadingComponent, loadingComponent,
errorComponent, errorComponent,
delay = 200, delay,
hydrate: hydrateStrategy, hydrate: hydrateStrategy,
timeout, // undefined = never times out timeout,
suspensible = true, suspensible = true,
onError: userOnError, },
} = source } = createAsyncComponentContext(source)
let pendingRequest: Promise<ConcreteComponent> | null = null
let resolvedComp: ConcreteComponent | undefined
let retries = 0
const retry = () => {
retries++
pendingRequest = null
return load()
}
const load = (): Promise<ConcreteComponent> => {
let thisRequest: Promise<ConcreteComponent>
return (
pendingRequest ||
(thisRequest = pendingRequest =
loader()
.catch(err => {
err = err instanceof Error ? err : new Error(String(err))
if (userOnError) {
return new Promise((resolve, reject) => {
const userRetry = () => resolve(retry())
const userFail = () => reject(err)
userOnError(err, userRetry, userFail, retries + 1)
})
} else {
throw err
}
})
.then((comp: any) => {
if (thisRequest !== pendingRequest && pendingRequest) {
return pendingRequest
}
if (__DEV__ && !comp) {
warn(
`Async component loader resolved to undefined. ` +
`If you are using retry(), make sure to return its return value.`,
)
}
// interop module default
if (
comp &&
(comp.__esModule || comp[Symbol.toStringTag] === 'Module')
) {
comp = comp.default
}
if (__DEV__ && comp && !isObject(comp) && !isFunction(comp)) {
throw new Error(`Invalid async component load result: ${comp}`)
}
resolvedComp = comp
return comp
}))
)
}
return defineComponent({ return defineComponent({
name: 'AsyncComponentWrapper', name: 'AsyncComponentWrapper',
@ -132,7 +77,7 @@ export function defineAsyncComponent<
} }
} }
: hydrate : hydrate
if (resolvedComp) { if (getResolvedComp()) {
doHydrate() doHydrate()
} else { } else {
load().then(() => !instance.isUnmounted && doHydrate()) load().then(() => !instance.isUnmounted && doHydrate())
@ -140,7 +85,7 @@ export function defineAsyncComponent<
}, },
get __asyncResolved() { get __asyncResolved() {
return resolvedComp return getResolvedComp()
}, },
setup() { setup() {
@ -148,12 +93,13 @@ export function defineAsyncComponent<
markAsyncBoundary(instance) markAsyncBoundary(instance)
// already resolved // already resolved
let resolvedComp = getResolvedComp()
if (resolvedComp) { if (resolvedComp) {
return () => createInnerComp(resolvedComp!, instance) return () => createInnerComp(resolvedComp!, instance)
} }
const onError = (err: Error) => { const onError = (err: Error) => {
pendingRequest = null setPendingRequest(null)
handleError( handleError(
err, err,
instance, instance,
@ -182,27 +128,11 @@ export function defineAsyncComponent<
}) })
} }
const loaded = ref(false) const { loaded, error, delayed } = useAsyncComponentState(
const error = ref() delay,
const delayed = ref(!!delay) timeout,
onError,
if (delay) {
setTimeout(() => {
delayed.value = false
}, delay)
}
if (timeout != null) {
setTimeout(() => {
if (!loaded.value && !error.value) {
const err = new Error(
`Async component timed out after ${timeout}ms.`,
) )
onError(err)
error.value = err
}
}, timeout)
}
load() load()
.then(() => { .then(() => {
@ -223,6 +153,7 @@ export function defineAsyncComponent<
}) })
return () => { return () => {
resolvedComp = getResolvedComp()
if (loaded.value && resolvedComp) { if (loaded.value && resolvedComp) {
return createInnerComp(resolvedComp, instance) return createInnerComp(resolvedComp, instance)
} else if (error.value && errorComponent) { } else if (error.value && errorComponent) {
@ -252,3 +183,114 @@ function createInnerComp(
return vnode return vnode
} }
type AsyncComponentContext<T, C = ConcreteComponent> = {
load: () => Promise<C>
source: AsyncComponentOptions<T>
getResolvedComp: () => C | undefined
setPendingRequest: (request: Promise<C> | null) => void
}
// shared between core and vapor
export function createAsyncComponentContext<T, C = ConcreteComponent>(
source: AsyncComponentLoader<T> | AsyncComponentOptions<T>,
): AsyncComponentContext<T, C> {
if (isFunction(source)) {
source = { loader: source }
}
const { loader, onError: userOnError } = source
let pendingRequest: Promise<C> | null = null
let resolvedComp: C | undefined
let retries = 0
const retry = () => {
retries++
pendingRequest = null
return load()
}
const load = (): Promise<C> => {
let thisRequest: Promise<C>
return (
pendingRequest ||
(thisRequest = pendingRequest =
loader()
.catch(err => {
err = err instanceof Error ? err : new Error(String(err))
if (userOnError) {
return new Promise((resolve, reject) => {
const userRetry = () => resolve(retry())
const userFail = () => reject(err)
userOnError(err, userRetry, userFail, retries + 1)
})
} else {
throw err
}
})
.then((comp: any) => {
if (thisRequest !== pendingRequest && pendingRequest) {
return pendingRequest
}
if (__DEV__ && !comp) {
warn(
`Async component loader resolved to undefined. ` +
`If you are using retry(), make sure to return its return value.`,
)
}
if (
comp &&
(comp.__esModule || comp[Symbol.toStringTag] === 'Module')
) {
comp = comp.default
}
if (__DEV__ && comp && !isObject(comp) && !isFunction(comp)) {
throw new Error(`Invalid async component load result: ${comp}`)
}
resolvedComp = comp
return comp
}))
)
}
return {
load,
source,
getResolvedComp: () => resolvedComp,
setPendingRequest: (request: Promise<C> | null) =>
(pendingRequest = request),
}
}
// shared between core and vapor
export const useAsyncComponentState = (
delay: number | undefined,
timeout: number | undefined,
onError: (err: Error) => void,
): {
loaded: Ref<boolean>
error: Ref<Error | undefined>
delayed: Ref<boolean>
} => {
const loaded = ref(false)
const error = ref()
const delayed = ref(!!delay)
if (delay) {
setTimeout(() => {
delayed.value = false
}, delay)
}
if (timeout != null) {
setTimeout(() => {
if (!loaded.value && !error.value) {
const err = new Error(`Async component timed out after ${timeout}ms.`)
onError(err)
error.value = err
}
}, timeout)
}
return { loaded, error, delayed }
}

View File

@ -27,7 +27,7 @@ import { warn } from './warning'
import type { VNode } from './vnode' import type { VNode } from './vnode'
import { devtoolsInitApp, devtoolsUnmountApp } from './devtools' import { devtoolsInitApp, devtoolsUnmountApp } from './devtools'
import { NO, extend, isFunction, isObject } from '@vue/shared' import { NO, extend, isFunction, isObject } from '@vue/shared'
import { version } from '.' import { type TransitionHooks, version } from '.'
import { installAppCompatProperties } from './compat/global' import { installAppCompatProperties } from './compat/global'
import type { NormalizedPropsOptions } from './componentProps' import type { NormalizedPropsOptions } from './componentProps'
import type { ObjectEmitsOptions } from './componentEmits' import type { ObjectEmitsOptions } from './componentEmits'
@ -174,7 +174,6 @@ export interface AppConfig extends GenericAppConfig {
/** /**
* The vapor in vdom implementation is in runtime-vapor/src/vdomInterop.ts * The vapor in vdom implementation is in runtime-vapor/src/vdomInterop.ts
* @internal
*/ */
export interface VaporInteropInterface { export interface VaporInteropInterface {
mount( mount(
@ -187,8 +186,18 @@ export interface VaporInteropInterface {
unmount(vnode: VNode, doRemove?: boolean): void unmount(vnode: VNode, doRemove?: boolean): void
move(vnode: VNode, container: any, anchor: any): void move(vnode: VNode, container: any, anchor: any): void
slot(n1: VNode | null, n2: VNode, container: any, anchor: any): void slot(n1: VNode | null, n2: VNode, container: any, anchor: any): void
setTransitionHooks(
component: ComponentInternalInstance,
transition: TransitionHooks,
): void
hydrate(node: Node, fn: () => void): void
vdomMount: (component: ConcreteComponent, props?: any, slots?: any) => any vdomMount: (
component: ConcreteComponent,
props?: any,
slots?: any,
scopeId?: string,
) => any
vdomUnmount: UnmountComponentFn vdomUnmount: UnmountComponentFn
vdomSlot: ( vdomSlot: (
slots: any, slots: any,

View File

@ -1,6 +1,8 @@
import { import {
type ComponentInternalInstance, type ComponentInternalInstance,
type ComponentOptions, type ComponentOptions,
type ConcreteComponent,
type GenericComponentInstance,
type SetupContext, type SetupContext,
getCurrentInstance, getCurrentInstance,
} from '../component' } from '../component'
@ -19,12 +21,12 @@ import { ErrorCodes, callWithAsyncErrorHandling } from '../errorHandling'
import { PatchFlags, ShapeFlags, isArray, isFunction } from '@vue/shared' import { PatchFlags, ShapeFlags, isArray, isFunction } from '@vue/shared'
import { onBeforeUnmount, onMounted } from '../apiLifecycle' import { onBeforeUnmount, onMounted } from '../apiLifecycle'
import { isTeleport } from './Teleport' import { isTeleport } from './Teleport'
import type { RendererElement } from '../renderer' import { type RendererElement, getVaporInterface } from '../renderer'
import { SchedulerJobFlags } from '../scheduler' import { SchedulerJobFlags } from '../scheduler'
type Hook<T = () => void> = T | T[] type Hook<T = () => void> = T | T[]
const leaveCbKey: unique symbol = Symbol('_leaveCb') export const leaveCbKey: unique symbol = Symbol('_leaveCb')
const enterCbKey: unique symbol = Symbol('_enterCb') const enterCbKey: unique symbol = Symbol('_enterCb')
export interface BaseTransitionProps<HostElement = RendererElement> { export interface BaseTransitionProps<HostElement = RendererElement> {
@ -87,7 +89,7 @@ export interface TransitionState {
isUnmounting: boolean isUnmounting: boolean
// Track pending leave callbacks for children of the same key. // Track pending leave callbacks for children of the same key.
// This is used to force remove leaving a child when a new copy is entering. // This is used to force remove leaving a child when a new copy is entering.
leavingVNodes: Map<any, Record<string, VNode>> leavingNodes: Map<any, Record<string, any>>
} }
export interface TransitionElement { export interface TransitionElement {
@ -103,7 +105,7 @@ export function useTransitionState(): TransitionState {
isMounted: false, isMounted: false,
isLeaving: false, isLeaving: false,
isUnmounting: false, isUnmounting: false,
leavingVNodes: new Map(), leavingNodes: new Map(),
} }
onMounted(() => { onMounted(() => {
state.isMounted = true state.isMounted = true
@ -138,7 +140,9 @@ export const BaseTransitionPropsValidators: Record<string, any> = {
} }
const recursiveGetSubtree = (instance: ComponentInternalInstance): VNode => { const recursiveGetSubtree = (instance: ComponentInternalInstance): VNode => {
const subTree = instance.subTree const subTree = instance.type.__vapor
? (instance as any).block
: instance.subTree
return subTree.component ? recursiveGetSubtree(subTree.component) : subTree return subTree.component ? recursiveGetSubtree(subTree.component) : subTree
} }
@ -164,15 +168,7 @@ const BaseTransitionImpl: ComponentOptions = {
const rawProps = toRaw(props) const rawProps = toRaw(props)
const { mode } = rawProps const { mode } = rawProps
// check mode // check mode
if ( checkTransitionMode(mode)
__DEV__ &&
mode &&
mode !== 'in-out' &&
mode !== 'out-in' &&
mode !== 'default'
) {
warn(`invalid <transition> mode: ${mode}`)
}
if (state.isLeaving) { if (state.isLeaving) {
return emptyPlaceholder(child) return emptyPlaceholder(child)
@ -309,24 +305,83 @@ function getLeavingNodesForType(
state: TransitionState, state: TransitionState,
vnode: VNode, vnode: VNode,
): Record<string, VNode> { ): Record<string, VNode> {
const { leavingVNodes } = state const { leavingNodes } = state
let leavingVNodesCache = leavingVNodes.get(vnode.type)! let leavingVNodesCache = leavingNodes.get(vnode.type)!
if (!leavingVNodesCache) { if (!leavingVNodesCache) {
leavingVNodesCache = Object.create(null) leavingVNodesCache = Object.create(null)
leavingVNodes.set(vnode.type, leavingVNodesCache) leavingNodes.set(vnode.type, leavingVNodesCache)
} }
return leavingVNodesCache return leavingVNodesCache
} }
export interface TransitionHooksContext {
setLeavingNodeCache: (node: any) => void
unsetLeavingNodeCache: (node: any) => void
earlyRemove: () => void
cloneHooks: (node: any) => TransitionHooks
}
// The transition hooks are attached to the vnode as vnode.transition // The transition hooks are attached to the vnode as vnode.transition
// and will be called at appropriate timing in the renderer. // and will be called at appropriate timing in the renderer.
export function resolveTransitionHooks( export function resolveTransitionHooks(
vnode: VNode, vnode: VNode,
props: BaseTransitionProps<any>, props: BaseTransitionProps<any>,
state: TransitionState, state: TransitionState,
instance: ComponentInternalInstance, instance: GenericComponentInstance,
postClone?: (hooks: TransitionHooks) => void, postClone?: (hooks: TransitionHooks) => void,
): TransitionHooks { ): TransitionHooks {
const key = String(vnode.key)
const leavingVNodesCache = getLeavingNodesForType(state, vnode)
const context: TransitionHooksContext = {
setLeavingNodeCache: () => {
leavingVNodesCache[key] = vnode
},
unsetLeavingNodeCache: () => {
if (leavingVNodesCache[key] === vnode) {
delete leavingVNodesCache[key]
}
},
earlyRemove: () => {
const leavingVNode = leavingVNodesCache[key]
if (
leavingVNode &&
isSameVNodeType(vnode, leavingVNode) &&
(leavingVNode.el as TransitionElement)[leaveCbKey]
) {
// force early removal (not cancelled)
;(leavingVNode.el as TransitionElement)[leaveCbKey]!()
}
},
cloneHooks: vnode => {
const hooks = resolveTransitionHooks(
vnode,
props,
state,
instance,
postClone,
)
if (postClone) postClone(hooks)
return hooks
},
}
return baseResolveTransitionHooks(context, props, state, instance)
}
// shared between vdom and vapor
export function baseResolveTransitionHooks(
context: TransitionHooksContext,
props: BaseTransitionProps<any>,
state: TransitionState,
instance: GenericComponentInstance,
): TransitionHooks {
const {
setLeavingNodeCache,
unsetLeavingNodeCache,
earlyRemove,
cloneHooks,
} = context
const { const {
appear, appear,
mode, mode,
@ -344,8 +399,6 @@ export function resolveTransitionHooks(
onAfterAppear, onAfterAppear,
onAppearCancelled, onAppearCancelled,
} = props } = props
const key = String(vnode.key)
const leavingVNodesCache = getLeavingNodesForType(state, vnode)
const callHook: TransitionHookCaller = (hook, args) => { const callHook: TransitionHookCaller = (hook, args) => {
hook && hook &&
@ -387,15 +440,7 @@ export function resolveTransitionHooks(
el[leaveCbKey](true /* cancelled */) el[leaveCbKey](true /* cancelled */)
} }
// for toggled element with same key (v-if) // for toggled element with same key (v-if)
const leavingVNode = leavingVNodesCache[key] earlyRemove()
if (
leavingVNode &&
isSameVNodeType(vnode, leavingVNode) &&
(leavingVNode.el as TransitionElement)[leaveCbKey]
) {
// force early removal (not cancelled)
;(leavingVNode.el as TransitionElement)[leaveCbKey]!()
}
callHook(hook, [el]) callHook(hook, [el])
}, },
@ -434,7 +479,7 @@ export function resolveTransitionHooks(
}, },
leave(el, remove) { leave(el, remove) {
const key = String(vnode.key) // const key = String(vnode.key)
if (el[enterCbKey]) { if (el[enterCbKey]) {
el[enterCbKey](true /* cancelled */) el[enterCbKey](true /* cancelled */)
} }
@ -453,11 +498,9 @@ export function resolveTransitionHooks(
callHook(onAfterLeave, [el]) callHook(onAfterLeave, [el])
} }
el[leaveCbKey] = undefined el[leaveCbKey] = undefined
if (leavingVNodesCache[key] === vnode) { unsetLeavingNodeCache(el)
delete leavingVNodesCache[key]
}
}) })
leavingVNodesCache[key] = vnode setLeavingNodeCache(el)
if (onLeave) { if (onLeave) {
callAsyncHook(onLeave, [el, done]) callAsyncHook(onLeave, [el, done])
} else { } else {
@ -465,16 +508,8 @@ export function resolveTransitionHooks(
} }
}, },
clone(vnode) { clone(node) {
const hooks = resolveTransitionHooks( return cloneHooks(node)
vnode,
props,
state,
instance,
postClone,
)
if (postClone) postClone(hooks)
return hooks
}, },
} }
@ -524,8 +559,15 @@ function getInnerChild(vnode: VNode): VNode | undefined {
export function setTransitionHooks(vnode: VNode, hooks: TransitionHooks): void { export function setTransitionHooks(vnode: VNode, hooks: TransitionHooks): void {
if (vnode.shapeFlag & ShapeFlags.COMPONENT && vnode.component) { if (vnode.shapeFlag & ShapeFlags.COMPONENT && vnode.component) {
if ((vnode.type as ConcreteComponent).__vapor) {
getVaporInterface(vnode.component, vnode).setTransitionHooks(
vnode.component,
hooks,
)
} else {
vnode.transition = hooks vnode.transition = hooks
setTransitionHooks(vnode.component.subTree, hooks) setTransitionHooks(vnode.component.subTree, hooks)
}
} else if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) { } else if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
vnode.ssContent!.transition = hooks.clone(vnode.ssContent!) vnode.ssContent!.transition = hooks.clone(vnode.ssContent!)
vnode.ssFallback!.transition = hooks.clone(vnode.ssFallback!) vnode.ssFallback!.transition = hooks.clone(vnode.ssFallback!)
@ -571,3 +613,18 @@ export function getTransitionRawChildren(
} }
return ret return ret
} }
/**
* dev-only
*/
export function checkTransitionMode(mode: string | undefined): void {
if (
__DEV__ &&
mode &&
mode !== 'in-out' &&
mode !== 'out-in' &&
mode !== 'default'
) {
warn(`invalid <transition> mode: ${mode}`)
}
}

View File

@ -27,10 +27,10 @@ export const TeleportEndKey: unique symbol = Symbol('_vte')
export const isTeleport = (type: any): boolean => type.__isTeleport export const isTeleport = (type: any): boolean => type.__isTeleport
const isTeleportDisabled = (props: VNode['props']): boolean => export const isTeleportDisabled = (props: VNode['props']): boolean =>
props && (props.disabled || props.disabled === '') props && (props.disabled || props.disabled === '')
const isTeleportDeferred = (props: VNode['props']): boolean => export const isTeleportDeferred = (props: VNode['props']): boolean =>
props && (props.defer || props.defer === '') props && (props.defer || props.defer === '')
const isTargetSVG = (target: RendererElement): boolean => const isTargetSVG = (target: RendererElement): boolean =>
@ -39,7 +39,7 @@ const isTargetSVG = (target: RendererElement): boolean =>
const isTargetMathML = (target: RendererElement): boolean => const isTargetMathML = (target: RendererElement): boolean =>
typeof MathMLElement === 'function' && target instanceof MathMLElement typeof MathMLElement === 'function' && target instanceof MathMLElement
const resolveTarget = <T = RendererElement>( export const resolveTarget = <T = RendererElement>(
props: TeleportProps | null, props: TeleportProps | null,
select: RendererOptions['querySelector'], select: RendererOptions['querySelector'],
): T | null => { ): T | null => {

View File

@ -81,6 +81,10 @@ export function renderSlot(
} }
openBlock() openBlock()
const validSlotContent = slot && ensureValidVNode(slot(props)) const validSlotContent = slot && ensureValidVNode(slot(props))
// handle forwarded vapor slot fallback
ensureVaporSlotFallback(validSlotContent, fallback)
const slotKey = const slotKey =
props.key || props.key ||
// slot content array of a dynamic conditional slot may have a branch // slot content array of a dynamic conditional slot may have a branch
@ -124,3 +128,20 @@ export function ensureValidVNode(
? vnodes ? vnodes
: null : null
} }
export function ensureVaporSlotFallback(
vnodes: VNodeArrayChildren | null | undefined,
fallback?: () => VNodeArrayChildren,
): void {
let vaporSlot: any
if (
vnodes &&
vnodes.length === 1 &&
isVNode(vnodes[0]) &&
(vaporSlot = vnodes[0].vs)
) {
if (!vaporSlot.fallback && fallback) {
vaporSlot.fallback = fallback
}
}
}

View File

@ -119,7 +119,7 @@ function reload(id: string, newComp: HMRComponent): void {
// create a snapshot which avoids the set being mutated during updates // create a snapshot which avoids the set being mutated during updates
const instances = [...record.instances] const instances = [...record.instances]
if (newComp.vapor) { if (newComp.__vapor) {
for (const instance of instances) { for (const instance of instances) {
instance.hmrReload!(newComp) instance.hmrReload!(newComp)
} }

View File

@ -31,11 +31,16 @@ import {
isRenderableAttrValue, isRenderableAttrValue,
isReservedProp, isReservedProp,
isString, isString,
isVaporAnchors,
normalizeClass, normalizeClass,
normalizeStyle, normalizeStyle,
stringifyStyle, stringifyStyle,
} from '@vue/shared' } from '@vue/shared'
import { type RendererInternals, needTransition } from './renderer' import {
type RendererInternals,
getVaporInterface,
needTransition,
} from './renderer'
import { setRef } from './rendererTemplateRef' import { setRef } from './rendererTemplateRef'
import { import {
type SuspenseBoundary, type SuspenseBoundary,
@ -111,7 +116,7 @@ export function createHydrationFunctions(
o: { o: {
patchProp, patchProp,
createText, createText,
nextSibling, nextSibling: next,
parentNode, parentNode,
remove, remove,
insert, insert,
@ -119,6 +124,15 @@ export function createHydrationFunctions(
}, },
} = rendererInternals } = rendererInternals
function nextSibling(node: Node) {
let n = next(node)
// skip vapor mode specific anchors
if (n && isVaporAnchors(n)) {
n = next(n)
}
return n
}
const hydrate: RootHydrateFunction = (vnode, container) => { const hydrate: RootHydrateFunction = (vnode, container) => {
if (!container.hasChildNodes()) { if (!container.hasChildNodes()) {
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
@ -145,6 +159,10 @@ export function createHydrationFunctions(
slotScopeIds: string[] | null, slotScopeIds: string[] | null,
optimized = false, optimized = false,
): Node | null => { ): Node | null => {
// skip vapor mode specific anchors
if (isVaporAnchors(node)) {
node = nextSibling(node)!
}
optimized = optimized || !!vnode.dynamicChildren optimized = optimized || !!vnode.dynamicChildren
const isFragmentStart = isComment(node) && node.data === '[' const isFragmentStart = isComment(node) && node.data === '['
const onMismatch = () => const onMismatch = () =>
@ -278,10 +296,6 @@ export function createHydrationFunctions(
) )
} }
} else if (shapeFlag & ShapeFlags.COMPONENT) { } else if (shapeFlag & ShapeFlags.COMPONENT) {
if ((vnode.type as ConcreteComponent).__vapor) {
throw new Error('Vapor component hydration is not supported yet.')
}
// when setting up the render effect, if the initial vnode already // when setting up the render effect, if the initial vnode already
// has .el set, the component will perform hydration instead of mount // has .el set, the component will perform hydration instead of mount
// on its sub-tree. // on its sub-tree.
@ -302,6 +316,13 @@ export function createHydrationFunctions(
nextNode = nextSibling(node) nextNode = nextSibling(node)
} }
// hydrate vapor component
if ((vnode.type as ConcreteComponent).__vapor) {
const vaporInterface = getVaporInterface(parentComponent, vnode)
vaporInterface.hydrate(node, () => {
vaporInterface.mount(vnode, container, null, parentComponent)
})
} else {
mountComponent( mountComponent(
vnode, vnode,
container, container,
@ -311,6 +332,7 @@ export function createHydrationFunctions(
getContainerType(container), getContainerType(container),
optimized, optimized,
) )
}
// #3787 // #3787
// if component is async, it may get moved / unmounted before its // if component is async, it may get moved / unmounted before its
@ -451,7 +473,7 @@ export function createHydrationFunctions(
// The SSRed DOM contains more nodes than it should. Remove them. // The SSRed DOM contains more nodes than it should. Remove them.
const cur = next const cur = next
next = next.nextSibling next = nextSibling(next)
remove(cur) remove(cur)
} }
} else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { } else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
@ -553,7 +575,7 @@ export function createHydrationFunctions(
} }
} }
return el.nextSibling return nextSibling(el)
} }
const hydrateChildren = ( const hydrateChildren = (

View File

@ -118,6 +118,7 @@ export { KeepAlive, type KeepAliveProps } from './components/KeepAlive'
export { export {
BaseTransition, BaseTransition,
BaseTransitionPropsValidators, BaseTransitionPropsValidators,
checkTransitionMode,
type BaseTransitionProps, type BaseTransitionProps,
} from './components/BaseTransition' } from './components/BaseTransition'
// For using custom directives // For using custom directives
@ -150,8 +151,10 @@ export { registerRuntimeCompiler, isRuntimeOnly } from './component'
export { export {
useTransitionState, useTransitionState,
resolveTransitionHooks, resolveTransitionHooks,
baseResolveTransitionHooks,
setTransitionHooks, setTransitionHooks,
getTransitionRawChildren, getTransitionRawChildren,
leaveCbKey,
} from './components/BaseTransition' } from './components/BaseTransition'
export { initCustomFormatter } from './customFormatter' export { initCustomFormatter } from './customFormatter'
@ -335,6 +338,8 @@ export type { SuspenseBoundary } from './components/Suspense'
export type { export type {
TransitionState, TransitionState,
TransitionHooks, TransitionHooks,
TransitionHooksContext,
TransitionElement,
} from './components/BaseTransition' } from './components/BaseTransition'
export type { export type {
AsyncComponentOptions, AsyncComponentOptions,
@ -505,7 +510,11 @@ export { type VaporInteropInterface } from './apiCreateApp'
/** /**
* @internal * @internal
*/ */
export { type RendererInternals, MoveType } from './renderer' export {
type RendererInternals,
MoveType,
getInheritedScopeIds,
} from './renderer'
/** /**
* @internal * @internal
*/ */
@ -557,6 +566,31 @@ export { startMeasure, endMeasure } from './profiling'
* @internal * @internal
*/ */
export { initFeatureFlags } from './featureFlags' export { initFeatureFlags } from './featureFlags'
/**
* @internal
*/
export { performTransitionEnter, performTransitionLeave } from './renderer'
/**
* @internal
*/
export { ensureVaporSlotFallback } from './helpers/renderSlot'
/**
* @internal
*/
export {
resolveTarget as resolveTeleportTarget,
isTeleportDisabled,
isTeleportDeferred,
} from './components/Teleport'
export {
createAsyncComponentContext,
useAsyncComponentState,
isAsyncWrapper,
} from './apiAsyncComponent'
/**
* @internal
*/
export { markAsyncBoundary } from './helpers/useId'
/** /**
* @internal * @internal
*/ */

View File

@ -107,6 +107,7 @@ export interface Renderer<HostElement = RendererElement> {
export interface HydrationRenderer extends Renderer<Element | ShadowRoot> { export interface HydrationRenderer extends Renderer<Element | ShadowRoot> {
hydrate: RootHydrateFunction hydrate: RootHydrateFunction
hydrateNode: ReturnType<typeof createHydrationFunctions>[1]
} }
export type ElementNamespace = 'svg' | 'mathml' | undefined export type ElementNamespace = 'svg' | 'mathml' | undefined
@ -731,19 +732,20 @@ function baseCreateRenderer(
} }
// #1583 For inside suspense + suspense not resolved case, enter hook should call when suspense resolved // #1583 For inside suspense + suspense not resolved case, enter hook should call when suspense resolved
// #1689 For inside suspense + suspense resolved case, just call it // #1689 For inside suspense + suspense resolved case, just call it
const needCallTransitionHooks = needTransition(parentSuspense, transition) if (transition) {
if (needCallTransitionHooks) { performTransitionEnter(
transition!.beforeEnter(el) el,
} transition,
() => hostInsert(el, container, anchor),
parentSuspense,
)
} else {
hostInsert(el, container, anchor) hostInsert(el, container, anchor)
if ( }
(vnodeHook = props && props.onVnodeMounted) ||
needCallTransitionHooks || if ((vnodeHook = props && props.onVnodeMounted) || dirs) {
dirs
) {
queuePostRenderEffect(() => { queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode) vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
needCallTransitionHooks && transition!.enter(el)
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted') dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
}, parentSuspense) }, parentSuspense)
} }
@ -764,30 +766,9 @@ function baseCreateRenderer(
hostSetScopeId(el, slotScopeIds[i]) hostSetScopeId(el, slotScopeIds[i])
} }
} }
let subTree = parentComponent && parentComponent.subTree const inheritedScopeIds = getInheritedScopeIds(vnode, parentComponent)
if (subTree) { for (let i = 0; i < inheritedScopeIds.length; i++) {
if ( hostSetScopeId(el, inheritedScopeIds[i])
__DEV__ &&
subTree.patchFlag > 0 &&
subTree.patchFlag & PatchFlags.DEV_ROOT_FRAGMENT
) {
subTree =
filterSingleRoot(subTree.children as VNodeArrayChildren) || subTree
}
if (
vnode === subTree ||
(isSuspense(subTree.type) &&
(subTree.ssContent === vnode || subTree.ssFallback === vnode))
) {
const parentVNode = parentComponent!.vnode!
setScopeId(
el,
parentVNode,
parentVNode.scopeId,
parentVNode.slotScopeIds,
parentComponent!.parent,
)
}
} }
} }
@ -2115,9 +2096,12 @@ function baseCreateRenderer(
transition transition
if (needTransition) { if (needTransition) {
if (moveType === MoveType.ENTER) { if (moveType === MoveType.ENTER) {
transition!.beforeEnter(el!) performTransitionEnter(
hostInsert(el!, container, anchor) el!,
queuePostRenderEffect(() => transition!.enter(el!), parentSuspense) transition,
() => hostInsert(el!, container, anchor),
parentSuspense,
)
} else { } else {
const { leave, delayLeave, afterLeave } = transition! const { leave, delayLeave, afterLeave } = transition!
const remove = () => { const remove = () => {
@ -2300,27 +2284,15 @@ function baseCreateRenderer(
return return
} }
const performRemove = () => { if (transition) {
performTransitionLeave(
el!,
transition,
() => hostRemove(el!),
!!(vnode.shapeFlag & ShapeFlags.ELEMENT),
)
} else {
hostRemove(el!) hostRemove(el!)
if (transition && !transition.persisted && transition.afterLeave) {
transition.afterLeave()
}
}
if (
vnode.shapeFlag & ShapeFlags.ELEMENT &&
transition &&
!transition.persisted
) {
const { leave, delayLeave } = transition
const performLeave = () => leave(el!, performRemove)
if (delayLeave) {
delayLeave(vnode.el!, performRemove, performLeave)
} else {
performLeave()
}
} else {
performRemove()
} }
} }
@ -2444,7 +2416,7 @@ function baseCreateRenderer(
const getNextHostNode: NextFn = vnode => { const getNextHostNode: NextFn = vnode => {
if (vnode.shapeFlag & ShapeFlags.COMPONENT) { if (vnode.shapeFlag & ShapeFlags.COMPONENT) {
if ((vnode.type as ConcreteComponent).__vapor) { if ((vnode.type as ConcreteComponent).__vapor) {
return hostNextSibling((vnode.component! as any).block) return hostNextSibling(vnode.anchor!)
} }
return getNextHostNode(vnode.component!.subTree) return getNextHostNode(vnode.component!.subTree)
} }
@ -2546,6 +2518,7 @@ function baseCreateRenderer(
return { return {
render, render,
hydrate, hydrate,
hydrateNode,
internals, internals,
createApp: createAppAPI( createApp: createAppAPI(
mountApp, mountApp,
@ -2648,7 +2621,7 @@ export function traverseStaticChildren(
function locateNonHydratedAsyncRoot( function locateNonHydratedAsyncRoot(
instance: ComponentInternalInstance, instance: ComponentInternalInstance,
): ComponentInternalInstance | undefined { ): ComponentInternalInstance | undefined {
const subComponent = instance.subTree.component const subComponent = instance.vapor ? null : instance.subTree.component
if (subComponent) { if (subComponent) {
if (subComponent.asyncDep && !subComponent.asyncResolved) { if (subComponent.asyncDep && !subComponent.asyncResolved) {
return subComponent return subComponent
@ -2665,7 +2638,50 @@ export function invalidateMount(hooks: LifecycleHook | undefined): void {
} }
} }
function getVaporInterface( // shared between vdom and vapor
export function performTransitionEnter(
el: RendererElement,
transition: TransitionHooks,
insert: () => void,
parentSuspense: SuspenseBoundary | null,
): void {
if (needTransition(parentSuspense, transition)) {
transition.beforeEnter(el)
insert()
queuePostRenderEffect(() => transition.enter(el), parentSuspense)
} else {
insert()
}
}
// shared between vdom and vapor
export function performTransitionLeave(
el: RendererElement,
transition: TransitionHooks,
remove: () => void,
isElement: boolean = true,
): void {
const performRemove = () => {
remove()
if (transition && !transition.persisted && transition.afterLeave) {
transition.afterLeave()
}
}
if (isElement && transition && !transition.persisted) {
const { leave, delayLeave } = transition
const performLeave = () => leave(el, performRemove)
if (delayLeave) {
delayLeave(el, performRemove, performLeave)
} else {
performLeave()
}
} else {
performRemove()
}
}
export function getVaporInterface(
instance: ComponentInternalInstance | null, instance: ComponentInternalInstance | null,
vnode: VNode, vnode: VNode,
): VaporInteropInterface { ): VaporInteropInterface {
@ -2682,3 +2698,54 @@ function getVaporInterface(
} }
return res! return res!
} }
/**
* shared between vdom and vapor
*/
export function getInheritedScopeIds(
vnode: VNode,
parentComponent: GenericComponentInstance | null,
): string[] {
const inheritedScopeIds: string[] = []
let currentParent = parentComponent
let currentVNode = vnode
while (currentParent) {
let subTree = currentParent.subTree
if (!subTree) break
if (
__DEV__ &&
subTree.patchFlag > 0 &&
subTree.patchFlag & PatchFlags.DEV_ROOT_FRAGMENT
) {
subTree =
filterSingleRoot(subTree.children as VNodeArrayChildren) || subTree
}
if (
currentVNode === subTree ||
(isSuspense(subTree.type) &&
(subTree.ssContent === currentVNode ||
subTree.ssFallback === currentVNode))
) {
const parentVNode = currentParent.vnode!
if (parentVNode.scopeId) {
inheritedScopeIds.push(parentVNode.scopeId)
}
if (parentVNode.slotScopeIds) {
inheritedScopeIds.push(...parentVNode.slotScopeIds)
}
currentVNode = parentVNode
currentParent = currentParent.parent
} else {
break
}
}
return inheritedScopeIds
}

View File

@ -32,7 +32,7 @@ import { extend } from '@vue/shared'
const positionMap = new WeakMap<VNode, DOMRect>() const positionMap = new WeakMap<VNode, DOMRect>()
const newPositionMap = new WeakMap<VNode, DOMRect>() const newPositionMap = new WeakMap<VNode, DOMRect>()
const moveCbKey = Symbol('_moveCb') export const moveCbKey: symbol = Symbol('_moveCb')
const enterCbKey = Symbol('_enterCb') const enterCbKey = Symbol('_enterCb')
export type TransitionGroupProps = Omit<TransitionProps, 'mode'> & { export type TransitionGroupProps = Omit<TransitionProps, 'mode'> & {
@ -88,7 +88,7 @@ const TransitionGroupImpl: ComponentOptions = /*@__PURE__*/ decorate({
// we divide the work into three loops to avoid mixing DOM reads and writes // we divide the work into three loops to avoid mixing DOM reads and writes
// in each iteration - which helps prevent layout thrashing. // in each iteration - which helps prevent layout thrashing.
prevChildren.forEach(callPendingCbs) prevChildren.forEach(vnode => callPendingCbs(vnode.el))
prevChildren.forEach(recordPosition) prevChildren.forEach(recordPosition)
const movedChildren = prevChildren.filter(applyTranslation) const movedChildren = prevChildren.filter(applyTranslation)
@ -97,20 +97,7 @@ const TransitionGroupImpl: ComponentOptions = /*@__PURE__*/ decorate({
movedChildren.forEach(c => { movedChildren.forEach(c => {
const el = c.el as ElementWithTransition const el = c.el as ElementWithTransition
const style = el.style handleMovedChildren(el, moveClass)
addTransitionClass(el, moveClass)
style.transform = style.webkitTransform = style.transitionDuration = ''
const cb = ((el as any)[moveCbKey] = (e: TransitionEvent) => {
if (e && e.target !== el) {
return
}
if (!e || /transform$/.test(e.propertyName)) {
el.removeEventListener('transitionend', cb)
;(el as any)[moveCbKey] = null
removeTransitionClass(el, moveClass)
}
})
el.addEventListener('transitionend', cb)
}) })
prevChildren = [] prevChildren = []
}) })
@ -179,8 +166,7 @@ export const TransitionGroup = TransitionGroupImpl as unknown as {
} }
} }
function callPendingCbs(c: VNode) { export function callPendingCbs(el: any): void {
const el = c.el as any
if (el[moveCbKey]) { if (el[moveCbKey]) {
el[moveCbKey]() el[moveCbKey]()
} }
@ -194,19 +180,36 @@ function recordPosition(c: VNode) {
} }
function applyTranslation(c: VNode): VNode | undefined { function applyTranslation(c: VNode): VNode | undefined {
const oldPos = positionMap.get(c)! if (
const newPos = newPositionMap.get(c)! baseApplyTranslation(
const dx = oldPos.left - newPos.left positionMap.get(c)!,
const dy = oldPos.top - newPos.top newPositionMap.get(c)!,
if (dx || dy) { c.el as ElementWithTransition,
const s = (c.el as HTMLElement).style )
s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)` ) {
s.transitionDuration = '0s'
return c return c
} }
} }
function hasCSSTransform( // shared between vdom and vapor
export function baseApplyTranslation(
oldPos: DOMRect,
newPos: DOMRect,
el: ElementWithTransition,
): boolean {
const dx = oldPos.left - newPos.left
const dy = oldPos.top - newPos.top
if (dx || dy) {
const s = (el as HTMLElement).style
s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`
s.transitionDuration = '0s'
return true
}
return false
}
// shared between vdom and vapor
export function hasCSSTransform(
el: ElementWithTransition, el: ElementWithTransition,
root: Node, root: Node,
moveClass: string, moveClass: string,
@ -233,3 +236,24 @@ function hasCSSTransform(
container.removeChild(clone) container.removeChild(clone)
return hasTransform return hasTransform
} }
// shared between vdom and vapor
export const handleMovedChildren = (
el: ElementWithTransition,
moveClass: string,
): void => {
const style = el.style
addTransitionClass(el, moveClass)
style.transform = style.webkitTransform = style.transitionDuration = ''
const cb = ((el as any)[moveCbKey] = (e: TransitionEvent) => {
if (e && e.target !== el) {
return
}
if (!e || /transform$/.test(e.propertyName)) {
el.removeEventListener('transitionend', cb)
;(el as any)[moveCbKey] = null
removeTransitionClass(el, moveClass)
}
})
el.addEventListener('transitionend', cb)
}

View File

@ -319,7 +319,7 @@ export * from './jsx'
/** /**
* @internal * @internal
*/ */
export { ensureRenderer, normalizeContainer } export { ensureRenderer, ensureHydrationRenderer, normalizeContainer }
/** /**
* @internal * @internal
*/ */
@ -348,3 +348,24 @@ export {
vModelSelectInit, vModelSelectInit,
vModelSetSelected, vModelSetSelected,
} from './directives/vModel' } from './directives/vModel'
/**
* @internal
*/
export {
resolveTransitionProps,
TransitionPropsValidators,
forceReflow,
addTransitionClass,
removeTransitionClass,
type ElementWithTransition,
} from './components/Transition'
/**
* @internal
*/
export {
hasCSSTransform,
callPendingCbs,
moveCbKey,
handleMovedChildren,
baseApplyTranslation,
} from './components/TransitionGroup'

View File

@ -110,4 +110,22 @@ describe('api: createDynamicComponent', () => {
await nextTick() await nextTick()
expect(html()).toBe('<div><div>B</div><!--dynamic-component--></div>') expect(html()).toBe('<div><div>B</div><!--dynamic-component--></div>')
}) })
test('render fallback with insertionState', async () => {
const { html, mount } = define({
setup() {
const html = ref('hi')
const n1 = template('<div></div>', true)() as any
setInsertionState(n1)
const n0 = createComponentWithFallback(
resolveDynamicComponent('button') as any,
) as any
renderEffect(() => setHtml(n0, html.value))
return n1
},
}).create()
mount()
expect(html()).toBe('<div><button>hi</button></div>')
})
}) })

View File

@ -0,0 +1,764 @@
import { nextTick, ref } from '@vue/runtime-dom'
import { type VaporComponent, createComponent } from '../src/component'
import { defineVaporAsyncComponent } from '../src/apiDefineAsyncComponent'
import { makeRender } from './_utils'
import {
createIf,
createTemplateRefSetter,
renderEffect,
template,
} from '@vue/runtime-vapor'
import { setElementText } from '../src/dom/prop'
const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n))
const define = makeRender()
describe('api: defineAsyncComponent', () => {
test('simple usage', async () => {
let resolve: (comp: VaporComponent) => void
const Foo = defineVaporAsyncComponent(
() =>
new Promise(r => {
resolve = r as any
}),
)
const toggle = ref(true)
const { html } = define({
setup() {
return createIf(
() => toggle.value,
() => {
return createComponent(Foo)
},
)
},
}).render()
expect(html()).toBe('<!--async component--><!--if-->')
resolve!(() => template('resolved')())
await timeout()
expect(html()).toBe('resolved<!--async component--><!--if-->')
toggle.value = false
await nextTick()
expect(html()).toBe('<!--if-->')
// already resolved component should update on nextTick
toggle.value = true
await nextTick()
expect(html()).toBe('resolved<!--async component--><!--if-->')
})
test('with loading component', async () => {
let resolve: (comp: VaporComponent) => void
const Foo = defineVaporAsyncComponent({
loader: () =>
new Promise(r => {
resolve = r as any
}),
loadingComponent: () => template('loading')(),
delay: 1, // defaults to 200
})
const toggle = ref(true)
const { html } = define({
setup() {
return createIf(
() => toggle.value,
() => {
return createComponent(Foo)
},
)
},
}).render()
// due to the delay, initial mount should be empty
expect(html()).toBe('<!--async component--><!--if-->')
// loading show up after delay
await timeout(1)
expect(html()).toBe('loading<!--async component--><!--if-->')
resolve!(() => template('resolved')())
await timeout()
expect(html()).toBe('resolved<!--async component--><!--if-->')
toggle.value = false
await nextTick()
expect(html()).toBe('<!--if-->')
// already resolved component should update on nextTick without loading
// state
toggle.value = true
await nextTick()
expect(html()).toBe('resolved<!--async component--><!--if-->')
})
test('with loading component + explicit delay (0)', async () => {
let resolve: (comp: VaporComponent) => void
const Foo = defineVaporAsyncComponent({
loader: () =>
new Promise(r => {
resolve = r as any
}),
loadingComponent: () => template('loading')(),
delay: 0,
})
const toggle = ref(true)
const { html } = define({
setup() {
return createIf(
() => toggle.value,
() => {
return createComponent(Foo)
},
)
},
}).render()
// with delay: 0, should show loading immediately
expect(html()).toBe('loading<!--async component--><!--if-->')
resolve!(() => template('resolved')())
await timeout()
expect(html()).toBe('resolved<!--async component--><!--if-->')
toggle.value = false
await nextTick()
expect(html()).toBe('<!--if-->')
// already resolved component should update on nextTick without loading
// state
toggle.value = true
await nextTick()
expect(html()).toBe('resolved<!--async component--><!--if-->')
})
test('error without error component', async () => {
let resolve: (comp: VaporComponent) => void
let reject: (e: Error) => void
const Foo = defineVaporAsyncComponent(
() =>
new Promise((_resolve, _reject) => {
resolve = _resolve as any
reject = _reject
}),
)
const toggle = ref(true)
const { app, mount } = define({
setup() {
return createIf(
() => toggle.value,
() => {
return createComponent(Foo)
},
)
},
}).create()
const handler = (app.config.errorHandler = vi.fn())
const root = document.createElement('div')
mount(root)
expect(root.innerHTML).toBe('<!--async component--><!--if-->')
const err = new Error('foo')
reject!(err)
await timeout()
expect(handler).toHaveBeenCalled()
expect(handler.mock.calls[0][0]).toBe(err)
expect(root.innerHTML).toBe('<!--async component--><!--if-->')
toggle.value = false
await nextTick()
expect(root.innerHTML).toBe('<!--if-->')
// errored out on previous load, toggle and mock success this time
toggle.value = true
await nextTick()
expect(root.innerHTML).toBe('<!--async component--><!--if-->')
// should render this time
resolve!(() => template('resolved')())
await timeout()
expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
})
test('error with error component', async () => {
let resolve: (comp: VaporComponent) => void
let reject: (e: Error) => void
const Foo = defineVaporAsyncComponent({
loader: () =>
new Promise((_resolve, _reject) => {
resolve = _resolve as any
reject = _reject
}),
errorComponent: (props: { error: Error }) =>
template(props.error.message)(),
})
const toggle = ref(true)
const { app, mount } = define({
setup() {
return createIf(
() => toggle.value,
() => {
return createComponent(Foo)
},
)
},
}).create()
const handler = (app.config.errorHandler = vi.fn())
const root = document.createElement('div')
mount(root)
expect(root.innerHTML).toBe('<!--async component--><!--if-->')
const err = new Error('errored out')
reject!(err)
await timeout()
expect(handler).toHaveBeenCalled()
expect(root.innerHTML).toBe('errored out<!--async component--><!--if-->')
toggle.value = false
await nextTick()
expect(root.innerHTML).toBe('<!--if-->')
// errored out on previous load, toggle and mock success this time
toggle.value = true
await nextTick()
expect(root.innerHTML).toBe('<!--async component--><!--if-->')
// should render this time
resolve!(() => template('resolved')())
await timeout()
expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
})
test('error with error component, without global handler', async () => {
let resolve: (comp: VaporComponent) => void
let reject: (e: Error) => void
const Foo = defineVaporAsyncComponent({
loader: () =>
new Promise((_resolve, _reject) => {
resolve = _resolve as any
reject = _reject
}),
errorComponent: (props: { error: Error }) =>
template(props.error.message)(),
})
const toggle = ref(true)
const { mount } = define({
setup() {
return createIf(
() => toggle.value,
() => {
return createComponent(Foo)
},
)
},
}).create()
const root = document.createElement('div')
mount(root)
expect(root.innerHTML).toBe('<!--async component--><!--if-->')
const err = new Error('errored out')
reject!(err)
await timeout()
expect(root.innerHTML).toBe('errored out<!--async component--><!--if-->')
expect(
'Unhandled error during execution of async component loader',
).toHaveBeenWarned()
toggle.value = false
await nextTick()
expect(root.innerHTML).toBe('<!--if-->')
// errored out on previous load, toggle and mock success this time
toggle.value = true
await nextTick()
expect(root.innerHTML).toBe('<!--async component--><!--if-->')
// should render this time
resolve!(() => template('resolved')())
await timeout()
expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
})
test('error with error + loading components', async () => {
let resolve: (comp: VaporComponent) => void
let reject: (e: Error) => void
const Foo = defineVaporAsyncComponent({
loader: () =>
new Promise((_resolve, _reject) => {
resolve = _resolve as any
reject = _reject
}),
errorComponent: (props: { error: Error }) =>
template(props.error.message)(),
loadingComponent: () => template('loading')(),
delay: 1,
})
const toggle = ref(true)
const { app, mount } = define({
setup() {
return createIf(
() => toggle.value,
() => {
return createComponent(Foo)
},
)
},
}).create()
const handler = (app.config.errorHandler = vi.fn())
const root = document.createElement('div')
mount(root)
// due to the delay, initial mount should be empty
expect(root.innerHTML).toBe('<!--async component--><!--if-->')
// loading show up after delay
await timeout(1)
expect(root.innerHTML).toBe('loading<!--async component--><!--if-->')
const err = new Error('errored out')
reject!(err)
await timeout()
expect(handler).toHaveBeenCalled()
expect(root.innerHTML).toBe('errored out<!--async component--><!--if-->')
toggle.value = false
await nextTick()
expect(root.innerHTML).toBe('<!--if-->')
// errored out on previous load, toggle and mock success this time
toggle.value = true
await nextTick()
expect(root.innerHTML).toBe('<!--async component--><!--if-->')
// loading show up after delay
await timeout(1)
expect(root.innerHTML).toBe('loading<!--async component--><!--if-->')
// should render this time
resolve!(() => template('resolved')())
await timeout()
expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
})
test('timeout without error component', async () => {
let resolve: (comp: VaporComponent) => void
const Foo = defineVaporAsyncComponent({
loader: () =>
new Promise(_resolve => {
resolve = _resolve as any
}),
timeout: 1,
})
const { app, mount } = define({
setup() {
return createComponent(Foo)
},
}).create()
const handler = vi.fn()
app.config.errorHandler = handler
const root = document.createElement('div')
mount(root)
expect(root.innerHTML).toBe('<!--async component-->')
await timeout(1)
expect(handler).toHaveBeenCalled()
expect(handler.mock.calls[0][0].message).toMatch(
`Async component timed out after 1ms.`,
)
expect(root.innerHTML).toBe('<!--async component-->')
// if it resolved after timeout, should still work
resolve!(() => template('resolved')())
await timeout()
expect(root.innerHTML).toBe('resolved<!--async component-->')
})
test('timeout with error component', async () => {
let resolve: (comp: VaporComponent) => void
const Foo = defineVaporAsyncComponent({
loader: () =>
new Promise(_resolve => {
resolve = _resolve as any
}),
timeout: 1,
errorComponent: () => template('timed out')(),
})
const root = document.createElement('div')
const { app, mount } = define({
setup() {
return createComponent(Foo)
},
}).create()
const handler = (app.config.errorHandler = vi.fn())
mount(root)
expect(root.innerHTML).toBe('<!--async component-->')
await timeout(1)
expect(handler).toHaveBeenCalled()
expect(root.innerHTML).toBe('timed out<!--async component-->')
// if it resolved after timeout, should still work
resolve!(() => template('resolved')())
await timeout()
expect(root.innerHTML).toBe('resolved<!--async component-->')
})
test('timeout with error + loading components', async () => {
let resolve: (comp: VaporComponent) => void
const Foo = defineVaporAsyncComponent({
loader: () =>
new Promise(_resolve => {
resolve = _resolve as any
}),
delay: 1,
timeout: 16,
errorComponent: () => template('timed out')(),
loadingComponent: () => template('loading')(),
})
const root = document.createElement('div')
const { app, mount } = define({
setup() {
return createComponent(Foo)
},
}).create()
const handler = (app.config.errorHandler = vi.fn())
mount(root)
expect(root.innerHTML).toBe('<!--async component-->')
await timeout(1)
expect(root.innerHTML).toBe('loading<!--async component-->')
await timeout(16)
expect(root.innerHTML).toBe('timed out<!--async component-->')
expect(handler).toHaveBeenCalled()
resolve!(() => template('resolved')())
await timeout()
expect(root.innerHTML).toBe('resolved<!--async component-->')
})
test('timeout without error component, but with loading component', async () => {
let resolve: (comp: VaporComponent) => void
const Foo = defineVaporAsyncComponent({
loader: () =>
new Promise(_resolve => {
resolve = _resolve as any
}),
delay: 1,
timeout: 16,
loadingComponent: () => template('loading')(),
})
const root = document.createElement('div')
const { app, mount } = define({
setup() {
return createComponent(Foo)
},
}).create()
const handler = vi.fn()
app.config.errorHandler = handler
mount(root)
expect(root.innerHTML).toBe('<!--async component-->')
await timeout(1)
expect(root.innerHTML).toBe('loading<!--async component-->')
await timeout(16)
expect(handler).toHaveBeenCalled()
expect(handler.mock.calls[0][0].message).toMatch(
`Async component timed out after 16ms.`,
)
// should still display loading
expect(root.innerHTML).toBe('loading<!--async component-->')
resolve!(() => template('resolved')())
await timeout()
expect(root.innerHTML).toBe('resolved<!--async component-->')
})
test('retry (success)', async () => {
let loaderCallCount = 0
let resolve: (comp: VaporComponent) => void
let reject: (e: Error) => void
const Foo = defineVaporAsyncComponent({
loader: () => {
loaderCallCount++
return new Promise((_resolve, _reject) => {
resolve = _resolve as any
reject = _reject
})
},
onError(error, retry, fail) {
if (error.message.match(/foo/)) {
retry()
} else {
fail()
}
},
})
const root = document.createElement('div')
const { app, mount } = define({
setup() {
return createComponent(Foo)
},
}).create()
const handler = (app.config.errorHandler = vi.fn())
mount(root)
expect(root.innerHTML).toBe('<!--async component-->')
expect(loaderCallCount).toBe(1)
const err = new Error('foo')
reject!(err)
await timeout()
expect(handler).not.toHaveBeenCalled()
expect(loaderCallCount).toBe(2)
expect(root.innerHTML).toBe('<!--async component-->')
// should render this time
resolve!(() => template('resolved')())
await timeout()
expect(handler).not.toHaveBeenCalled()
expect(root.innerHTML).toBe('resolved<!--async component-->')
})
test('retry (skipped)', async () => {
let loaderCallCount = 0
let reject: (e: Error) => void
const Foo = defineVaporAsyncComponent({
loader: () => {
loaderCallCount++
return new Promise((_resolve, _reject) => {
reject = _reject
})
},
onError(error, retry, fail) {
if (error.message.match(/bar/)) {
retry()
} else {
fail()
}
},
})
const root = document.createElement('div')
const { app, mount } = define({
setup() {
return createComponent(Foo)
},
}).create()
const handler = (app.config.errorHandler = vi.fn())
mount(root)
expect(root.innerHTML).toBe('<!--async component-->')
expect(loaderCallCount).toBe(1)
const err = new Error('foo')
reject!(err)
await timeout()
// should fail because retryWhen returns false
expect(handler).toHaveBeenCalled()
expect(handler.mock.calls[0][0]).toBe(err)
expect(loaderCallCount).toBe(1)
expect(root.innerHTML).toBe('<!--async component-->')
})
test('retry (fail w/ max retry attempts)', async () => {
let loaderCallCount = 0
let reject: (e: Error) => void
const Foo = defineVaporAsyncComponent({
loader: () => {
loaderCallCount++
return new Promise((_resolve, _reject) => {
reject = _reject
})
},
onError(error, retry, fail, attempts) {
if (error.message.match(/foo/) && attempts <= 1) {
retry()
} else {
fail()
}
},
})
const root = document.createElement('div')
const { app, mount } = define({
setup() {
return createComponent(Foo)
},
}).create()
const handler = (app.config.errorHandler = vi.fn())
mount(root)
expect(root.innerHTML).toBe('<!--async component-->')
expect(loaderCallCount).toBe(1)
// first retry
const err = new Error('foo')
reject!(err)
await timeout()
expect(handler).not.toHaveBeenCalled()
expect(loaderCallCount).toBe(2)
expect(root.innerHTML).toBe('<!--async component-->')
// 2nd retry, should fail due to reaching maxRetries
reject!(err)
await timeout()
expect(handler).toHaveBeenCalled()
expect(handler.mock.calls[0][0]).toBe(err)
expect(loaderCallCount).toBe(2)
expect(root.innerHTML).toBe('<!--async component-->')
})
test('template ref forwarding', async () => {
let resolve: (comp: VaporComponent) => void
const Foo = defineVaporAsyncComponent(
() =>
new Promise(r => {
resolve = r as any
}),
)
const fooRef = ref<any>(null)
const toggle = ref(true)
const root = document.createElement('div')
const { mount } = define({
setup() {
return { fooRef, toggle }
},
render() {
return createIf(
() => toggle.value,
() => {
const setTemplateRef = createTemplateRefSetter()
const n0 = createComponent(Foo, null, null, true)
setTemplateRef(n0, 'fooRef')
return n0
},
)
},
}).create()
mount(root)
expect(root.innerHTML).toBe('<!--async component--><!--if-->')
expect(fooRef.value).toBe(null)
resolve!({
setup: (props, { expose }) => {
expose({
id: 'foo',
})
return template('resolved')()
},
})
// first time resolve, wait for macro task since there are multiple
// microtasks / .then() calls
await timeout()
expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
expect(fooRef.value.id).toBe('foo')
toggle.value = false
await nextTick()
expect(root.innerHTML).toBe('<!--if-->')
expect(fooRef.value).toBe(null)
// already resolved component should update on nextTick
toggle.value = true
await nextTick()
expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
expect(fooRef.value.id).toBe('foo')
})
test('the forwarded template ref should always exist when doing multi patching', async () => {
let resolve: (comp: VaporComponent) => void
const Foo = defineVaporAsyncComponent(
() =>
new Promise(r => {
resolve = r as any
}),
)
const fooRef = ref<any>(null)
const toggle = ref(true)
const updater = ref(0)
const root = document.createElement('div')
const { mount } = define({
setup() {
return { fooRef, toggle, updater }
},
render() {
return createIf(
() => toggle.value,
() => {
const setTemplateRef = createTemplateRefSetter()
const n0 = createComponent(Foo, null, null, true)
setTemplateRef(n0, 'fooRef')
const n1 = template(`<span>`)()
renderEffect(() => setElementText(n1, updater.value))
return [n0, n1]
},
)
},
}).create()
mount(root)
expect(root.innerHTML).toBe('<!--async component--><span>0</span><!--if-->')
expect(fooRef.value).toBe(null)
resolve!({
setup: (props, { expose }) => {
expose({
id: 'foo',
})
return template('resolved')()
},
})
await timeout()
expect(root.innerHTML).toBe(
'resolved<!--async component--><span>0</span><!--if-->',
)
expect(fooRef.value.id).toBe('foo')
updater.value++
await nextTick()
expect(root.innerHTML).toBe(
'resolved<!--async component--><span>1</span><!--if-->',
)
expect(fooRef.value.id).toBe('foo')
toggle.value = false
await nextTick()
expect(root.innerHTML).toBe('<!--if-->')
expect(fooRef.value).toBe(null)
})
test.todo('with suspense', async () => {})
test.todo('suspensible: false', async () => {})
test.todo('suspense with error handling', async () => {})
test.todo('with KeepAlive', async () => {})
test.todo('with KeepAlive + include', async () => {})
})

View File

@ -1,10 +1,5 @@
import { import { insert, normalizeBlock, prepend, remove } from '../src/block'
VaporFragment, import { VaporFragment } from '../src/fragment'
insert,
normalizeBlock,
prepend,
remove,
} from '../src/block'
const node1 = document.createTextNode('node1') const node1 = document.createTextNode('node1')
const node2 = document.createTextNode('node2') const node2 = document.createTextNode('node2')

View File

@ -4,12 +4,18 @@
// ./rendererAttrsFallthrough.spec.ts. // ./rendererAttrsFallthrough.spec.ts.
import { import {
createApp,
h,
isEmitListener, isEmitListener,
nextTick, nextTick,
onBeforeUnmount, onBeforeUnmount,
toHandlers, toHandlers,
} from '@vue/runtime-dom' } from '@vue/runtime-dom'
import { createComponent, defineVaporComponent } from '../src' import {
createComponent,
defineVaporComponent,
vaporInteropPlugin,
} from '../src'
import { makeRender } from './_utils' import { makeRender } from './_utils'
const define = makeRender() const define = makeRender()
@ -425,3 +431,28 @@ describe('component: emit', () => {
expect(fn).not.toHaveBeenCalled() expect(fn).not.toHaveBeenCalled()
}) })
}) })
describe('vdom interop', () => {
test('vdom parent > vapor child', () => {
const VaporChild = defineVaporComponent({
emits: ['click'],
setup(_, { emit }) {
emit('click')
return []
},
})
const fn = vi.fn()
const App = {
setup() {
return () => h(VaporChild as any, { onClick: fn })
},
}
const root = document.createElement('div')
createApp(App).use(vaporInteropPlugin).mount(root)
// fn should be called once
expect(fn).toHaveBeenCalledTimes(1)
})
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More