Răsfoiți Sursa

fix(hmr): align child component HMR reload with parent rerender

daiwei 2 săptămâni în urmă
părinte
comite
e251b379c1

+ 7 - 1
packages/runtime-core/src/hmr.ts

@@ -143,7 +143,13 @@ function reload(id: string, newComp: HMRComponent): void {
   // create a snapshot which avoids the set being mutated during updates
   const instances = [...record.instances]
 
-  if (isVapor && newComp.__vapor && !instances.some(i => i.ceReload)) {
+  if (
+    isVapor &&
+    newComp.__vapor &&
+    // VDOM parents need the VDOM HMR path to remount dirty Vapor children.
+    !instances.some(instance => instance.parent && !instance.parent.vapor) &&
+    !instances.some(i => i.ceReload)
+  ) {
     // For multiple instances with the same __hmrId, remove styles first before reload
     // to avoid the second instance's style removal deleting the first instance's
     // newly added styles (since hmrReload is synchronous)

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

@@ -371,6 +371,7 @@ describe('renderer: VaporTeleport', () => {
           return [n0]
         },
       })
+      await nextTick()
       expect(root.innerHTML).toBe(
         '<!--teleport start--><!--teleport end--><div>root</div>',
       )

+ 47 - 4
packages/runtime-vapor/__tests__/hmr.spec.ts

@@ -324,6 +324,9 @@ describe('hot module replacement', () => {
     expect(
       '[Vue warn]: Unhandled error during execution of setup function',
     ).toHaveBeenWarned()
+    expect(
+      '[Vue warn]: Unhandled error during execution of render function',
+    ).toHaveBeenWarned()
     expect(
       '[HMR] Something went wrong during Vue component hot-reload.',
     ).toHaveBeenWarned()
@@ -662,9 +665,7 @@ describe('hot module replacement', () => {
     expect(deactivatedSpy).toHaveBeenCalledTimes(1)
   })
 
-  // TODO: renderEffect not re-run after child reload
-  // it requires parent rerender to align with vdom
-  test.todo('reload: avoid infinite recursion', async () => {
+  test('reload child through parent rerender', async () => {
     const root = document.createElement('div')
     document.body.appendChild(root)
     const childId = 'test-child-6930'
@@ -711,14 +712,18 @@ describe('hot module replacement', () => {
     reload(childId, {
       __hmrId: childId,
       __vapor: true,
-      setup() {
+      setup(_, { expose }) {
         onMounted(mountSpy)
         const count = ref(1)
+        expose({
+          count,
+        })
         return { count }
       },
       render: compileToFunction(`<div @click="count++">{{ count }}</div>`),
     })
     await nextTick()
+    await nextTick()
     expect(root.innerHTML).toBe(`<div>1</div><div>1</div>1`)
     expect(unmountSpy).toHaveBeenCalledTimes(2)
     expect(mountSpy).toHaveBeenCalledTimes(2)
@@ -1531,6 +1536,44 @@ describe('hot module replacement', () => {
   })
 
   describe('switch vapor/vdom modes', () => {
+    test('reload vapor child under vdom parent should rerender parent', async () => {
+      const id = 'vapor-child-under-vdom-parent'
+      const Child = {
+        __vapor: true,
+        __hmrId: id,
+        render() {
+          return template('<div>foo</div>')()
+        },
+      }
+      createRecord(id, Child)
+
+      let parentRenderCount = 0
+      const Parent = {
+        render() {
+          parentRenderCount++
+          return h(Child as any)
+        },
+      }
+      const root = document.createElement('div')
+      const app = createApp(Parent)
+      app.use(vaporInteropPlugin)
+      app.mount(root)
+      expect(root.innerHTML).toBe('<div>foo</div>')
+      expect(parentRenderCount).toBe(1)
+
+      reload(id, {
+        __vapor: true,
+        __hmrId: id,
+        render() {
+          return template('<div>bar</div>')()
+        },
+      })
+
+      await nextTick()
+      expect(root.innerHTML).toBe('<div>bar</div>')
+      expect(parentRenderCount).toBe(2)
+    })
+
     test('vapor -> vdom', async () => {
       const id = 'vapor-to-vdom'
       const Comp = {

+ 0 - 2
packages/runtime-vapor/src/component.ts

@@ -106,7 +106,6 @@ import {
   withDeferredHydrationBoundary,
 } from './dom/hydration'
 import { createComment, createElement, createTextNode } from './dom/node'
-import type { TeleportFragment } from './components/Teleport'
 import {
   isTeleportEnabled,
   isTeleportFragment,
@@ -749,7 +748,6 @@ export class VaporComponentInstance<
   devtoolsRawSetupState?: any
   hmrRerender?: () => void
   hmrReload?: (newComp: VaporComponent) => void
-  parentTeleport?: TeleportFragment | null
   propsOptions?: NormalizedPropsOptions
   emitsOptions?: ObjectEmitsOptions | null
   isSingleRoot?: boolean

+ 0 - 10
packages/runtime-vapor/src/components/Teleport.ts

@@ -170,16 +170,6 @@ export class TeleportFragment extends VaporFragment {
     if (this.parentComponent && this.parentComponent.ut) {
       this.registerUpdateCssVars(block)
     }
-
-    if (__DEV__) {
-      if (isVaporComponent(block)) {
-        block.parentTeleport = this
-      } else if (isArray(block)) {
-        block.forEach(
-          node => isVaporComponent(node) && (node.parentTeleport = this),
-        )
-      }
-    }
   }
 
   private handleChildrenUpdate(children: Block): void {

+ 13 - 84
packages/runtime-vapor/src/hmr.ts

@@ -1,22 +1,17 @@
 import {
-  isKeepAlive,
   popWarningContext,
   pushWarningContext,
   setCurrentInstance,
 } from '@vue/runtime-dom'
-import { type Block, findBlockBoundary, insert, remove } from './block'
+import { findBlockBoundary, insert, remove } from './block'
 import {
   type VaporComponent,
   type VaporComponentInstance,
   createComponent,
   devRender,
-  isVaporComponent,
   mountComponent,
   unmountComponent,
 } from './component'
-import { isArray } from '@vue/shared'
-import { isFragment } from './fragment'
-import { isKeepAliveEnabled } from './keepAlive'
 
 export function hmrRerender(instance: VaporComponentInstance): void {
   const { parentNode, nextNode: anchor } = findBlockBoundary(instance.block)
@@ -39,16 +34,20 @@ export function hmrReload(
   instance: VaporComponentInstance,
   newComp: VaporComponent,
 ): void {
-  // If parent is KeepAlive, rerender it so new component goes through
-  // KeepAlive's slot rendering flow to receive activated hooks properly
-  if (isKeepAliveEnabled && instance.parent && isKeepAlive(instance.parent)) {
-    instance.parent.hmrRerender!()
+  const parentInstance = instance.parent
+
+  // Align child reloads with VDOM HMR: rerender the parent instead of
+  // surgically swapping the child instance. A local swap can leave parent
+  // block ownership, component refs, or exposed instances pointing at the old
+  // instance.
+  if (parentInstance) {
+    parentInstance.hmrRerender!()
     return
   }
+
   const { parentNode, nextNode: anchor } = findBlockBoundary(instance.block)
   const parent = parentNode as ParentNode
   unmountComponent(instance, parent)
-  const parentInstance = instance.parent as VaporComponentInstance | null
   const prev = setCurrentInstance(parentInstance)
   let newInstance: VaporComponentInstance
   try {
@@ -65,78 +64,8 @@ export function hmrReload(
   }
   mountComponent(newInstance, parent, anchor)
 
-  if (!parentInstance) {
-    const app = instance.appContext.app
-    if (app && app._instance === instance) {
-      app._instance = newInstance
-    }
-  }
-
-  updateParentBlockOnHmrReload(parentInstance, instance, newInstance)
-  updateParentTeleportOnHmrReload(instance, newInstance)
-}
-
-/**
- * dev only
- * update parentInstance.block to ensure that the correct parent and
- * anchor are found during parentInstance HMR rerender/reload, as
- * `findBlockBoundary` relies on the current instance.block
- */
-function updateParentBlockOnHmrReload(
-  parentInstance: VaporComponentInstance | null,
-  instance: VaporComponentInstance,
-  newInstance: VaporComponentInstance,
-): void {
-  if (parentInstance) {
-    parentInstance.block = replaceBlockInstance(
-      parentInstance.block,
-      instance,
-      newInstance,
-    )
-  }
-}
-
-/**
- * dev only
- * during root component HMR reload, since the old component will be unmounted
- * and a new one will be mounted, we need to update the teleport's nodes
- * to ensure that the correct parent and anchor are found during parentInstance
- * HMR rerender/reload, as `findBlockBoundary` relies on the current instance.block
- */
-export function updateParentTeleportOnHmrReload(
-  instance: VaporComponentInstance,
-  newInstance: VaporComponentInstance,
-): void {
-  const teleport = instance.parentTeleport
-  if (teleport) {
-    newInstance.parentTeleport = teleport
-    teleport.nodes = replaceBlockInstance(teleport.nodes, instance, newInstance)
+  const app = instance.appContext.app
+  if (app && app._instance === instance) {
+    app._instance = newInstance
   }
 }
-
-function replaceBlockInstance(
-  block: Block,
-  instance: VaporComponentInstance,
-  newInstance: VaporComponentInstance,
-): Block {
-  if (block === instance) return newInstance
-
-  if (isArray(block)) {
-    for (let i = 0; i < block.length; i++) {
-      block[i] = replaceBlockInstance(block[i], instance, newInstance)
-    }
-    return block
-  }
-
-  if (isVaporComponent(block)) {
-    block.block = replaceBlockInstance(block.block, instance, newInstance)
-    return block
-  }
-
-  if (isFragment(block)) {
-    block.nodes = replaceBlockInstance(block.nodes, instance, newInstance)
-    return block
-  }
-
-  return block
-}