Skip to content
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ dts-build/packages
*.tsbuildinfo
*.tgz
packages-private/benchmark/reference
.claude
99 changes: 99 additions & 0 deletions packages-private/vapor-e2e-test/__tests__/teleport.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import path from 'node:path'
import {
E2E_TIMEOUT,
setupPuppeteer,
} from '../../../packages/vue/__tests__/e2e/e2eUtils'
import connect from 'connect'
import sirv from 'sirv'

const { page, click, html } = setupPuppeteer()

describe('vapor teleport', () => {
let server: any
const port = '8197'

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')
})

describe('teleport with moveBefore', () => {
test(
'should preserve video playback state when toggling disabled',
async () => {
const btnSelector = '#toggleDisabled'
const targetSelector = '.teleport-move-test > .target'
const mainSelector = '.teleport-move-test > .main'

// wait for video to start playing
await page().waitForFunction(() => {
const video = document.querySelector(
'.teleport-move-test video',
) as HTMLVideoElement
return video && video.currentTime > 0
})

// video should be in target initially (disabled=false)
expect(await html(targetSelector)).toContain('<video')
expect(await html(mainSelector)).not.toContain('<video')

// record current time and playing state
const timeBefore = await page().evaluate(() =>
(window as any).getVideoTime(),
)
const playingBefore = await page().evaluate(() =>
(window as any).isVideoPlaying(),
)
expect(playingBefore).toBe(true)
expect(timeBefore).toBeGreaterThan(0)

// toggle disabled (move to main)
await click(btnSelector)

// video should now be in main
expect(await html(mainSelector)).toContain('<video')
expect(await html(targetSelector)).not.toContain('<video')

// check video state is preserved (time should be >= before, still playing)
const timeAfter = await page().evaluate(() =>
(window as any).getVideoTime(),
)
const playingAfter = await page().evaluate(() =>
(window as any).isVideoPlaying(),
)

// video should still be playing
expect(playingAfter).toBe(true)
// time should have continued (not reset to 0)
// allow small tolerance for timing
expect(timeAfter).toBeGreaterThanOrEqual(timeBefore)

// toggle back (move to target)
await click(btnSelector)

// video should be back in target
expect(await html(targetSelector)).toContain('<video')
expect(await html(mainSelector)).not.toContain('<video')

// should still be playing
const playingFinal = await page().evaluate(() =>
(window as any).isVideoPlaying(),
)
expect(playingFinal).toBe(true)
},
E2E_TIMEOUT,
)
})
})
1 change: 1 addition & 0 deletions packages-private/vapor-e2e-test/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<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 {
Expand Down
80 changes: 80 additions & 0 deletions packages-private/vapor-e2e-test/teleport/App.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<script setup lang="ts" vapor>
import { ref } from 'vue'

const disabled = ref(false)

function toggleDisabled() {
disabled.value = !disabled.value
}

// expose for e2e test
;(window as any).getVideoTime = () => {
const video = document.querySelector(
'.teleport-move-test video',
) as HTMLVideoElement
return video ? video.currentTime : 0
}
;(window as any).isVideoPlaying = () => {
const video = document.querySelector(
'.teleport-move-test video',
) as HTMLVideoElement
return video ? !video.paused : false
}
</script>

<template>
<div class="teleport-container">
<!-- Test: Teleport disabled toggle should preserve video state -->
<div class="teleport-move-test">
<div class="target"></div>
<div class="main">
<Teleport to=".teleport-move-test > .target" :disabled="disabled">
<div class="content">
<video
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm"
width="200"
autoplay
muted
loop
></video>
</div>
</Teleport>
</div>
<button id="toggleDisabled" @click="toggleDisabled">
Toggle Disabled ({{ disabled }})
</button>
</div>
</div>
</template>

<style>
.teleport-container > div {
padding: 15px;
border: 1px solid #ccc;
margin: 10px 0;
}

.target {
border: 2px dashed blue;
min-height: 50px;
padding: 10px;
}

.target::before {
content: 'Target';
color: blue;
font-size: 12px;
}

.main {
border: 2px dashed green;
min-height: 50px;
padding: 10px;
}

.main::before {
content: 'Main';
color: green;
font-size: 12px;
}
</style>
2 changes: 2 additions & 0 deletions packages-private/vapor-e2e-test/teleport/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<script type="module" src="./main.ts"></script>
<div id="app"></div>
4 changes: 4 additions & 0 deletions packages-private/vapor-e2e-test/teleport/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createVaporApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createVaporApp(App).use(vaporInteropPlugin).mount('#app')
1 change: 1 addition & 0 deletions packages-private/vapor-e2e-test/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default defineConfig({
import.meta.dirname,
'transition-group/index.html',
),
teleport: resolve(import.meta.dirname, 'teleport/index.html'),
},
},
},
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime-core/src/components/Teleport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,8 @@ function moveTeleport(
parentAnchor,
MoveType.REORDER,
parentComponent,
null,
true, // preserveState - nodes are already mounted
)
}
}
Expand Down
19 changes: 15 additions & 4 deletions packages/runtime-core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,12 @@ export interface RendererOptions<
namespace?: ElementNamespace,
parentComponent?: ComponentInternalInstance | null,
): void
insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void
insert(
el: HostNode,
parent: HostElement,
anchor?: HostNode | null,
preserveState?: boolean,
): void
remove(el: HostNode): void
createElement(
type: string,
Expand Down Expand Up @@ -250,6 +255,7 @@ type MoveFn = (
type: MoveType,
parentComponent: ComponentInternalInstance | null,
parentSuspense?: SuspenseBoundary | null,
preserveState?: boolean,
) => void

type NextFn = (vnode: VNode) => RendererNode | null
Expand Down Expand Up @@ -2201,6 +2207,7 @@ function baseCreateRenderer(
moveType,
parentComponent,
parentSuspense = null,
preserveState,
) => {
const { el, type, transition, children, shapeFlag } = vnode
if (shapeFlag & ShapeFlags.COMPONENT) {
Expand All @@ -2213,6 +2220,8 @@ function baseCreateRenderer(
anchor,
moveType,
parentComponent,
parentSuspense,
preserveState,
)
}
return
Expand All @@ -2235,17 +2244,19 @@ function baseCreateRenderer(
}

if (type === Fragment) {
hostInsert(el!, container, anchor)
hostInsert(el!, container, anchor, preserveState)
for (let i = 0; i < (children as VNode[]).length; i++) {
move(
(children as VNode[])[i],
container,
anchor,
moveType,
parentComponent,
parentSuspense,
preserveState,
)
}
hostInsert(vnode.anchor!, container, anchor)
hostInsert(vnode.anchor!, container, anchor, preserveState)
return
}

Expand Down Expand Up @@ -2296,7 +2307,7 @@ function baseCreateRenderer(
}
}
} else {
hostInsert(el!, container, anchor)
hostInsert(el!, container, anchor, preserveState)
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,4 +382,4 @@ export {
/**
* @internal
*/
export { unsafeToTrustedHTML } from './nodeOps'
export { unsafeToTrustedHTML, moveNode } from './nodeOps'
25 changes: 23 additions & 2 deletions packages/runtime-dom/src/nodeOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,30 @@ const doc = (typeof document !== 'undefined' ? document : null) as Document

const templateContainer = doc && /*@__PURE__*/ doc.createElement('template')

/**
* Move a node to a new position
* Prefers moveBefore (preserves node state), falls back to insertBefore
*/
/*@__NO_SIDE_EFFECTS__*/
export const moveNode: (
parent: ParentNode & {
moveBefore?: ParentNode['insertBefore']
},
child: Node,
anchor: Node | null,
) => void =
/*@__PURE__*/ (() =>
typeof HTMLElement !== 'undefined' && 'moveBefore' in HTMLElement.prototype
? (parent, child, anchor) => parent.moveBefore!(child, anchor)
: (parent, child, anchor) => parent.insertBefore(child, anchor))()

export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
insert: (child, parent, anchor, preserveState) => {
if (preserveState) {
moveNode(parent, child, anchor || null)
} else {
parent.insertBefore(child, anchor || null)
}
},

remove: child => {
Expand Down
Loading
Loading