Browse Source

fix(runtime-vapor): delay teleport child init until target is available (#14593)

edison 1 month ago
parent
commit
3941eaba6e

+ 128 - 0
packages/runtime-vapor/__tests__/components/Teleport.spec.ts

@@ -6,6 +6,7 @@ import {
 } from '../../src/component'
 import {
   type VaporDirective,
+  VaporKeepAlive,
   VaporTeleport,
   VaporTransition,
   child,
@@ -26,7 +27,9 @@ import { makeRender } from '../_utils'
 import {
   h,
   nextTick,
+  onActivated,
   onBeforeUnmount,
+  onDeactivated,
   onMounted,
   onUnmounted,
   reactive,
@@ -1449,6 +1452,131 @@ test('should not duplicate main-view anchors when keyed list reorders teleport r
   expect(countAnchors('end')).toBe(2)
 })
 
+test('should delay child setup until teleport target becomes available', async () => {
+  const version = ref('one')
+  const target = ref<any>('#missing-teleport-target')
+  const setups: string[] = []
+
+  const Child = defineVaporComponent({
+    props: { msg: String },
+    setup(props) {
+      setups.push(String(props.msg))
+      const n0 = template('<div> </div>')() as any
+      const x0 = child(n0) as any
+      renderEffect(() => setText(x0, String(props.msg)))
+      return n0
+    },
+  })
+
+  const { mount } = define({
+    setup() {
+      return createComponent(
+        VaporTeleport,
+        { to: () => target.value },
+        {
+          default: () => {
+            const current = version.value
+            return createComponent(Child, { msg: () => current })
+          },
+        },
+      )
+    },
+  }).create()
+
+  const root = document.createElement('div')
+  mount(root)
+  expect('Failed to locate Teleport target').toHaveBeenWarned()
+  expect('Invalid Teleport target on mount').toHaveBeenWarned()
+  expect(setups).toEqual([])
+  expect(root.innerHTML).toBe('<!--teleport start--><!--teleport end-->')
+
+  version.value = 'two'
+  await nextTick()
+  version.value = 'three'
+  await nextTick()
+  expect(setups).toEqual([])
+
+  const targetEl = document.createElement('div')
+  target.value = targetEl
+  await nextTick()
+
+  expect(setups).toEqual(['three'])
+  expect(targetEl.innerHTML).toBe('<div>three</div>')
+})
+
+test('should cache delayed teleported child under KeepAlive once target becomes available', async () => {
+  const show = ref(true)
+  const target = ref<any>('#missing-teleport-target-keepalive')
+  const hooks = {
+    mounted: vi.fn(),
+    activated: vi.fn(),
+    deactivated: vi.fn(),
+    unmounted: vi.fn(),
+  }
+
+  const Child = defineVaporComponent({
+    name: 'DelayedTeleportKeepAliveChild',
+    setup() {
+      onMounted(hooks.mounted)
+      onActivated(hooks.activated)
+      onDeactivated(hooks.deactivated)
+      onUnmounted(hooks.unmounted)
+      return template('<div>child</div>')()
+    },
+  })
+
+  const { mount } = define({
+    setup() {
+      return createComponent(VaporKeepAlive, null, {
+        default: () =>
+          createIf(
+            () => show.value,
+            () =>
+              createComponent(
+                VaporTeleport,
+                { to: () => target.value },
+                {
+                  default: () => createComponent(Child),
+                },
+              ),
+          ),
+      })
+    },
+  }).create()
+
+  const root = document.createElement('div')
+  mount(root)
+
+  expect('Failed to locate Teleport target').toHaveBeenWarned()
+  expect('Invalid Teleport target on mount').toHaveBeenWarned()
+  expect(hooks.mounted).toHaveBeenCalledTimes(0)
+  expect(root.innerHTML).toBe(
+    '<!--teleport start--><!--teleport end--><!--if-->',
+  )
+
+  const targetEl = document.createElement('div')
+  target.value = targetEl
+  await nextTick()
+
+  expect(targetEl.innerHTML).toBe('<div>child</div>')
+  expect(hooks.mounted).toHaveBeenCalledTimes(1)
+  expect(hooks.activated).toHaveBeenCalledTimes(1)
+
+  show.value = false
+  await nextTick()
+
+  expect(targetEl.innerHTML).toBe('')
+  expect(hooks.deactivated).toHaveBeenCalledTimes(1)
+  expect(hooks.unmounted).toHaveBeenCalledTimes(0)
+
+  show.value = true
+  await nextTick()
+
+  expect(targetEl.innerHTML).toBe('<div>child</div>')
+  expect(hooks.mounted).toHaveBeenCalledTimes(1)
+  expect(hooks.activated).toHaveBeenCalledTimes(2)
+})
+
 test('should reapply css vars when teleport root children are replaced', async () => {
   const target = document.createElement('div')
   document.body.appendChild(target)

+ 59 - 0
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -9,6 +9,7 @@ import {
   delegateEvents,
   renderEffect,
   setStyle,
+  setText,
   template,
   useVaporCssVars,
 } from '../src'
@@ -3996,6 +3997,64 @@ describe('Vapor Mode hydration', () => {
       expect('Failed to locate Teleport target').toHaveBeenWarned()
     })
 
+    test('enabled teleport with null target should delay child setup until target becomes available', async () => {
+      const version = ref('one')
+      const target = ref<any>('#non-existent-target-hydrate-late')
+      const setups: string[] = []
+
+      const Child = defineVaporComponent({
+        props: { msg: String },
+        setup(props) {
+          setups.push(String(props.msg))
+          const n0 = template('<div> </div>')() as any
+          const x0 = child(n0) as any
+          renderEffect(() => setText(x0, String(props.msg)))
+          return n0
+        },
+      })
+
+      const App = defineVaporComponent({
+        setup() {
+          return createComponent(
+            VaporTeleport,
+            { to: () => target.value },
+            {
+              default: () => {
+                const current = version.value
+                return createComponent(Child, { msg: () => current })
+              },
+            },
+          )
+        },
+      })
+
+      const container = document.createElement('div')
+      container.innerHTML = '<!--teleport start--><!--teleport end-->'
+      document.body.appendChild(container)
+
+      const app = createVaporSSRApp(App)
+      app.mount(container)
+
+      expect(container.innerHTML).toBe(
+        `<!--teleport start--><!--teleport end-->`,
+      )
+      expect(setups).toEqual([])
+      expect('Failed to locate Teleport target').toHaveBeenWarned()
+
+      version.value = 'two'
+      await nextTick()
+      version.value = 'three'
+      await nextTick()
+      expect(setups).toEqual([])
+
+      const targetEl = document.createElement('div')
+      target.value = targetEl
+      await nextTick()
+
+      expect(setups).toEqual(['three'])
+      expect(targetEl.innerHTML).toBe('<div>three</div>')
+    })
+
     test('should apply css vars after hydration', async () => {
       const state = reactive({ color: 'red' })
 

+ 47 - 17
packages/runtime-vapor/src/components/Teleport.ts

@@ -10,6 +10,7 @@ import {
   isTeleportDisabled,
   queuePostFlushCb,
   resolveTeleportTarget,
+  setCurrentInstance,
   warn,
 } from '@vue/runtime-dom'
 import {
@@ -24,12 +25,15 @@ import { createComment, createTextNode, querySelector } from '../dom/node'
 import {
   type LooseRawProps,
   type LooseRawSlots,
+  type VaporComponentInstance,
+  currentInstance,
   isVaporComponent,
 } from '../component'
 import { rawPropsProxyHandlers } from '../componentProps'
 import { renderEffect } from '../renderEffect'
 import { extend, isArray } from '@vue/shared'
 import { VaporFragment, isFragment } from '../fragment'
+import { currentKeepAliveCtx, setCurrentKeepAliveCtx } from './KeepAlive'
 import {
   advanceHydrationNode,
   currentHydrationNode,
@@ -40,7 +44,11 @@ import {
   setCurrentHydrationNode,
 } from '../dom/hydration'
 import type { DefineVaporSetupFnComponent } from '../apiDefineComponent'
-import { getScopeOwner } from '../componentSlots'
+import {
+  currentSlotOwner,
+  getScopeOwner,
+  setCurrentSlotOwner,
+} from '../componentSlots'
 
 const VaporTeleportImpl = {
   name: 'VaporTeleport',
@@ -64,6 +72,11 @@ export class TeleportFragment extends VaporFragment {
   private rawSlots?: LooseRawSlots
   isDisabled?: boolean
   private isMounted = false
+  private childrenInitialized = false
+  private readonly ownerInstance =
+    currentInstance as VaporComponentInstance | null
+  private readonly slotOwner = currentSlotOwner
+  private readonly keepAliveCtx = currentKeepAliveCtx
 
   target?: ParentNode | null
   targetAnchor?: Node | null
@@ -104,10 +117,6 @@ export class TeleportFragment extends VaporFragment {
         this.handlePropsUpdate()
       }
     })
-
-    if (!isHydrating) {
-      this.initChildren()
-    }
   }
 
   get parent(): ParentNode | null {
@@ -115,12 +124,31 @@ export class TeleportFragment extends VaporFragment {
   }
 
   private initChildren(): void {
-    renderEffect(() => {
-      this.handleChildrenUpdate(
-        this.rawSlots!.default && (this.rawSlots!.default as BlockFn)(),
-      )
-    })
-    this.bindChildren(this.nodes)
+    const prevInstance = setCurrentInstance(this.ownerInstance)
+    try {
+      this.childrenInitialized = true
+      renderEffect(() => {
+        const prevOwner = setCurrentSlotOwner(this.slotOwner)
+        const prevKeepAliveCtx = setCurrentKeepAliveCtx(this.keepAliveCtx)
+        try {
+          this.handleChildrenUpdate(
+            this.rawSlots!.default && (this.rawSlots!.default as BlockFn)(),
+          )
+        } finally {
+          setCurrentKeepAliveCtx(prevKeepAliveCtx)
+          setCurrentSlotOwner(prevOwner)
+        }
+      })
+      this.bindChildren(this.nodes)
+    } finally {
+      setCurrentInstance(...prevInstance)
+    }
+  }
+
+  private ensureChildrenInitialized(): void {
+    if (!this.childrenInitialized) {
+      this.initChildren()
+    }
   }
 
   private registerUpdateCssVars(block: Block) {
@@ -155,8 +183,7 @@ export class TeleportFragment extends VaporFragment {
   }
 
   private handleChildrenUpdate(children: Block): void {
-    // not mounted yet
-    if (!this.parent || isHydrating || !this.mountContainer) {
+    if (isHydrating || !this.parent || !this.mountContainer) {
       this.nodes = children
       return
     }
@@ -216,6 +243,8 @@ export class TeleportFragment extends VaporFragment {
         insert((this.targetAnchor = createTextNode('')), target)
       }
 
+      this.ensureChildrenInitialized()
+
       // track CE teleport targets
       if (this.parentComponent && this.parentComponent.isCE) {
         ;(
@@ -240,6 +269,7 @@ export class TeleportFragment extends VaporFragment {
 
     // mount into main container
     if (this.isDisabled) {
+      this.ensureChildrenInitialized()
       this.mount(this.parent, this.anchor!)
     }
     // mount into target container
@@ -275,8 +305,8 @@ export class TeleportFragment extends VaporFragment {
 
   remove = (parent: ParentNode | undefined = this.parent!): void => {
     // remove nodes
-    if (this.nodes) {
-      remove(this.nodes, this.mountContainer!)
+    if (this.nodes && this.mountContainer) {
+      remove(this.nodes, this.mountContainer)
       this.nodes = []
     }
 
@@ -405,9 +435,9 @@ export class TeleportFragment extends VaporFragment {
       this.hydrateDisabledTeleport(null, null)
     } else {
       // Align with VDOM Teleport hydration: keep main-view markers only and
-      // avoid mounting children inline when the target is missing.
+      // avoid mounting children inline or eagerly initializing them when the
+      // target is missing.
       this.anchor = locateTeleportEndAnchor(currentHydrationNode!.nextSibling!)!
-      runWithoutHydration(this.initChildren.bind(this))
     }
 
     if (target || disabled) {