fix(hmr): hmr reload should work with async component (#11248)

This commit is contained in:
_Kerman 2024-07-15 21:54:53 +08:00 committed by GitHub
parent 1676f079b6
commit c8b9794575
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 80 additions and 33 deletions

View File

@ -29,6 +29,8 @@ function compileToFunction(template: string) {
return render return render
} }
const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n))
describe('hot module replacement', () => { describe('hot module replacement', () => {
test('inject global runtime', () => { test('inject global runtime', () => {
expect(createRecord).toBeDefined() expect(createRecord).toBeDefined()
@ -436,18 +438,23 @@ describe('hot module replacement', () => {
const Parent: ComponentOptions = { const Parent: ComponentOptions = {
setup() { setup() {
const com = ref() const com1 = ref()
const changeRef = (value: any) => { const changeRef1 = (value: any) => (com1.value = value)
com.value = value
}
return () => [h(Child, { ref: changeRef }), com.value?.count] const com2 = ref()
const changeRef2 = (value: any) => (com2.value = value)
return () => [
h(Child, { ref: changeRef1 }),
h(Child, { ref: changeRef2 }),
com1.value?.count,
]
}, },
} }
render(h(Parent), root) render(h(Parent), root)
await nextTick() await nextTick()
expect(serializeInner(root)).toBe(`<div>0</div>0`) expect(serializeInner(root)).toBe(`<div>0</div><div>0</div>0`)
reload(childId, { reload(childId, {
__hmrId: childId, __hmrId: childId,
@ -458,9 +465,9 @@ describe('hot module replacement', () => {
render: compileToFunction(`<div @click="count++">{{ count }}</div>`), render: compileToFunction(`<div @click="count++">{{ count }}</div>`),
}) })
await nextTick() await nextTick()
expect(serializeInner(root)).toBe(`<div>1</div>1`) expect(serializeInner(root)).toBe(`<div>1</div><div>1</div>1`)
expect(unmountSpy).toHaveBeenCalledTimes(1) expect(unmountSpy).toHaveBeenCalledTimes(2)
expect(mountSpy).toHaveBeenCalledTimes(1) expect(mountSpy).toHaveBeenCalledTimes(2)
}) })
// #1156 - static nodes should retain DOM element reference across updates // #1156 - static nodes should retain DOM element reference across updates
@ -805,4 +812,43 @@ describe('hot module replacement', () => {
`<div><div>1<p>3</p></div></div><div><div>1<p>3</p></div></div><p>2</p>`, `<div><div>1<p>3</p></div></div><div><div>1<p>3</p></div></div><p>2</p>`,
) )
}) })
// #11248
test('reload async component with multiple instances', async () => {
const root = nodeOps.createElement('div')
const childId = 'test-child-id'
const Child: ComponentOptions = {
__hmrId: childId,
data() {
return { count: 0 }
},
render: compileToFunction(`<div>{{ count }}</div>`),
}
const Comp = runtimeTest.defineAsyncComponent(() => Promise.resolve(Child))
const appId = 'test-app-id'
const App: ComponentOptions = {
__hmrId: appId,
render: () => [h(Comp), h(Comp)],
}
createRecord(appId, App)
render(h(App), root)
await timeout()
expect(serializeInner(root)).toBe(`<div>0</div><div>0</div>`)
// change count to 1
reload(childId, {
__hmrId: childId,
data() {
return { count: 1 }
},
render: compileToFunction(`<div>{{ count }}</div>`),
})
await timeout()
expect(serializeInner(root)).toBe(`<div>1</div><div>1</div>`)
})
}) })

View File

@ -14,7 +14,10 @@ type HMRComponent = ComponentOptions | ClassComponent
export let isHmrUpdating = false export let isHmrUpdating = false
export const hmrDirtyComponents = new Set<ConcreteComponent>() export const hmrDirtyComponents = new Map<
ConcreteComponent,
Set<ComponentInternalInstance>
>()
export interface HMRRuntime { export interface HMRRuntime {
createRecord: typeof createRecord createRecord: typeof createRecord
@ -110,18 +113,21 @@ function reload(id: string, newComp: HMRComponent) {
// 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]
for (const instance of instances) { for (let i = 0; i < instances.length; i++) {
const instance = instances[i]
const oldComp = normalizeClassComponent(instance.type as HMRComponent) const oldComp = normalizeClassComponent(instance.type as HMRComponent)
if (!hmrDirtyComponents.has(oldComp)) { let dirtyInstances = hmrDirtyComponents.get(oldComp)
if (!dirtyInstances) {
// 1. Update existing comp definition to match new one // 1. Update existing comp definition to match new one
if (oldComp !== record.initialDef) { if (oldComp !== record.initialDef) {
updateComponentDef(oldComp, newComp) updateComponentDef(oldComp, newComp)
} }
// 2. mark definition dirty. This forces the renderer to replace the // 2. mark definition dirty. This forces the renderer to replace the
// component on patch. // component on patch.
hmrDirtyComponents.add(oldComp) hmrDirtyComponents.set(oldComp, (dirtyInstances = new Set()))
} }
dirtyInstances.add(instance)
// 3. invalidate options resolution cache // 3. invalidate options resolution cache
instance.appContext.propsCache.delete(instance.type as any) instance.appContext.propsCache.delete(instance.type as any)
@ -131,9 +137,9 @@ function reload(id: string, newComp: HMRComponent) {
// 4. actually update // 4. actually update
if (instance.ceReload) { if (instance.ceReload) {
// custom element // custom element
hmrDirtyComponents.add(oldComp) dirtyInstances.add(instance)
instance.ceReload((newComp as any).styles) instance.ceReload((newComp as any).styles)
hmrDirtyComponents.delete(oldComp) dirtyInstances.delete(instance)
} else if (instance.parent) { } else if (instance.parent) {
// 4. Force the parent instance to re-render. This will cause all updated // 4. Force the parent instance to re-render. This will cause all updated
// components to be unmounted and re-mounted. Queue the update so that we // components to be unmounted and re-mounted. Queue the update so that we
@ -141,8 +147,8 @@ function reload(id: string, newComp: HMRComponent) {
instance.parent.effect.dirty = true instance.parent.effect.dirty = true
queueJob(() => { queueJob(() => {
instance.parent!.update() instance.parent!.update()
// #6930 avoid infinite recursion // #6930, #11248 avoid infinite recursion
hmrDirtyComponents.delete(oldComp) dirtyInstances.delete(instance)
}) })
} else if (instance.appContext.reload) { } else if (instance.appContext.reload) {
// root instance mounted via createApp() has a reload method // root instance mounted via createApp() has a reload method
@ -159,11 +165,7 @@ function reload(id: string, newComp: HMRComponent) {
// 5. make sure to cleanup dirty hmr components after update // 5. make sure to cleanup dirty hmr components after update
queuePostFlushCb(() => { queuePostFlushCb(() => {
for (const instance of instances) { hmrDirtyComponents.clear()
hmrDirtyComponents.delete(
normalizeClassComponent(instance.type as HMRComponent),
)
}
}) })
} }

View File

@ -387,17 +387,16 @@ export function isVNode(value: any): value is VNode {
} }
export function isSameVNodeType(n1: VNode, n2: VNode): boolean { export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
if ( if (__DEV__ && n2.shapeFlag & ShapeFlags.COMPONENT && n1.component) {
__DEV__ && const dirtyInstances = hmrDirtyComponents.get(n2.type as ConcreteComponent)
n2.shapeFlag & ShapeFlags.COMPONENT && if (dirtyInstances && dirtyInstances.has(n1.component)) {
hmrDirtyComponents.has(n2.type as ConcreteComponent) // #7042, ensure the vnode being unmounted during HMR
) { // bitwise operations to remove keep alive flags
// #7042, ensure the vnode being unmounted during HMR n1.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
// bitwise operations to remove keep alive flags n2.shapeFlag &= ~ShapeFlags.COMPONENT_KEPT_ALIVE
n1.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE // HMR only: if the component has been hot-updated, force a reload.
n2.shapeFlag &= ~ShapeFlags.COMPONENT_KEPT_ALIVE return false
// HMR only: if the component has been hot-updated, force a reload. }
return false
} }
return n1.type === n2.type && n1.key === n2.key return n1.type === n2.type && n1.key === n2.key
} }