Browse Source

feat(vapor): use moveBefore for teleport to preserve node state

daiwei 3 months ago
parent
commit
21d3409979

+ 1 - 0
.gitignore

@@ -12,3 +12,4 @@ dts-build/packages
 *.tsbuildinfo
 *.tgz
 packages-private/benchmark/reference
+.claude

+ 99 - 0
packages-private/vapor-e2e-test/__tests__/teleport.spec.ts

@@ -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 - 0.5)
+
+        // 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,
+    )
+  })
+})

+ 2 - 1
packages-private/vapor-e2e-test/index.html

@@ -2,10 +2,11 @@
 <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>
+</style>

+ 71 - 0
packages-private/vapor-e2e-test/teleport/App.vue

@@ -0,0 +1,71 @@
+<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 - 0
packages-private/vapor-e2e-test/teleport/index.html

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

+ 4 - 0
packages-private/vapor-e2e-test/teleport/main.ts

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

+ 1 - 0
packages-private/vapor-e2e-test/vite.config.ts

@@ -19,6 +19,7 @@ export default defineConfig({
           import.meta.dirname,
           'transition-group/index.html',
         ),
+        teleport: resolve(import.meta.dirname, 'teleport/index.html'),
       },
     },
   },

+ 23 - 4
packages/runtime-vapor/src/block.ts

@@ -5,7 +5,7 @@ import {
   mountComponent,
   unmountComponent,
 } from './component'
-import { _child } from './dom/node'
+import { _child, moveNode } from './dom/node'
 import { isComment, isHydrating } from './dom/hydration'
 import {
   MoveType,
@@ -130,6 +130,7 @@ export function move(
   moveType: MoveType = MoveType.LEAVE,
   parentComponent?: VaporComponentInstance,
   parentSuspense?: any, // TODO Suspense
+  preserveState?: boolean, // use moveBefore to preserve node state when possible
 ): void {
   anchor = anchor === 0 ? parent.$fc || _child(parent) : anchor
   if (block instanceof Node) {
@@ -144,7 +145,10 @@ export function move(
         performTransitionEnter(
           block,
           (block as TransitionBlock).$transition as TransitionHooks,
-          () => parent.insertBefore(block, anchor as Node),
+          () =>
+            preserveState
+              ? moveNode(parent, block, anchor as Node | null)
+              : parent.insertBefore(block, anchor as Node | null),
           parentSuspense,
           true,
         )
@@ -161,14 +165,18 @@ export function move(
               parentComponent.isUnmounted
             ) {
               block.remove()
+            } else if (preserveState) {
+              moveNode(parent, block, anchor as Node | null)
             } else {
-              parent.insertBefore(block, anchor as Node)
+              parent.insertBefore(block, anchor as Node | null)
             }
           },
           parentSuspense,
           true,
         )
       }
+    } else if (preserveState) {
+      moveNode(parent, block, anchor)
     } else {
       parent.insertBefore(block, anchor)
     }
@@ -181,13 +189,22 @@ export function move(
         moveType,
         parentComponent,
         parentSuspense,
+        preserveState,
       )
     } else {
       mountComponent(block, parent, anchor)
     }
   } else if (isArray(block)) {
     for (const b of block) {
-      move(b, parent, anchor, moveType, parentComponent, parentSuspense)
+      move(
+        b,
+        parent,
+        anchor,
+        moveType,
+        parentComponent,
+        parentSuspense,
+        preserveState,
+      )
     }
   } else {
     if (block.anchor) {
@@ -198,6 +215,7 @@ export function move(
         moveType,
         parentComponent,
         parentSuspense,
+        preserveState,
       )
       anchor = block.anchor
     }
@@ -212,6 +230,7 @@ export function move(
         moveType,
         parentComponent,
         parentSuspense,
+        preserveState,
       )
     }
   }

+ 21 - 5
packages/runtime-vapor/src/components/Teleport.ts

@@ -16,6 +16,7 @@ import {
   type BlockFn,
   applyTransitionHooks,
   insert,
+  move,
   remove,
 } from '../block'
 import { createComment, createTextNode, querySelector } from '../dom/node'
@@ -59,6 +60,7 @@ export class TeleportFragment extends VaporFragment {
   private resolvedProps?: TeleportProps
   private rawSlots?: LooseRawSlots
   isDisabled?: boolean
+  private isMounted = false
 
   target?: ParentNode | null
   targetAnchor?: Node | null
@@ -156,11 +158,25 @@ export class TeleportFragment extends VaporFragment {
     if (this.$transition) {
       applyTransitionHooks(this.nodes, this.$transition)
     }
-    insert(
-      this.nodes,
-      (this.mountContainer = parent),
-      (this.mountAnchor = anchor),
-    )
+    if (this.isMounted) {
+      // use preserveState to keep iframe/video state when moving
+      move(
+        this.nodes,
+        (this.mountContainer = parent),
+        (this.mountAnchor = anchor),
+        undefined,
+        undefined,
+        undefined,
+        true,
+      )
+    } else {
+      insert(
+        this.nodes,
+        (this.mountContainer = parent),
+        (this.mountAnchor = anchor),
+      )
+      this.isMounted = true
+    }
   }
 
   private mountToTarget(): void {

+ 14 - 0
packages/runtime-vapor/src/dom/node.ts

@@ -26,6 +26,20 @@ export function parentNode(node: Node): ParentNode | null {
   return node.parentNode
 }
 
+/**
+ * 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'] },
+  node: Node,
+  anchor: Node | null,
+) => void = /*@__PURE__*/ (() =>
+  'moveBefore' in HTMLElement.prototype
+    ? (parent, node, anchor) => parent.moveBefore!(node, anchor)
+    : (parent, node, anchor) => parent.insertBefore(node, anchor))()
+
 /*@__NO_SIDE_EFFECTS__*/
 const _txt: typeof _child = _child