Skip to content

fix: hmr reload should work with async component #11248

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 15, 2024
64 changes: 55 additions & 9 deletions packages/runtime-core/__tests__/hmr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ function compileToFunction(template: string) {
return render
}

const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n))

describe('hot module replacement', () => {
test('inject global runtime', () => {
expect(createRecord).toBeDefined()
Expand Down Expand Up @@ -436,18 +438,23 @@ describe('hot module replacement', () => {

const Parent: ComponentOptions = {
setup() {
const com = ref()
const changeRef = (value: any) => {
com.value = value
}
const com1 = ref()
const changeRef1 = (value: any) => (com1.value = value)

const com2 = ref()
const changeRef2 = (value: any) => (com2.value = value)

return () => [h(Child, { ref: changeRef }), com.value?.count]
return () => [
h(Child, { ref: changeRef1 }),
h(Child, { ref: changeRef2 }),
com1.value?.count,
]
},
}

render(h(Parent), root)
await nextTick()
expect(serializeInner(root)).toBe(`<div>0</div>0`)
expect(serializeInner(root)).toBe(`<div>0</div><div>0</div>0`)

reload(childId, {
__hmrId: childId,
Expand All @@ -458,9 +465,9 @@ describe('hot module replacement', () => {
render: compileToFunction(`<div @click="count++">{{ count }}</div>`),
})
await nextTick()
expect(serializeInner(root)).toBe(`<div>1</div>1`)
expect(unmountSpy).toHaveBeenCalledTimes(1)
expect(mountSpy).toHaveBeenCalledTimes(1)
expect(serializeInner(root)).toBe(`<div>1</div><div>1</div>1`)
expect(unmountSpy).toHaveBeenCalledTimes(2)
expect(mountSpy).toHaveBeenCalledTimes(2)
})

// #1156 - static nodes should retain DOM element reference across updates
Expand Down Expand Up @@ -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>`,
)
})

// #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>`)
})
})
28 changes: 15 additions & 13 deletions packages/runtime-core/src/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ type HMRComponent = ComponentOptions | ClassComponent

export let isHmrUpdating = false

export const hmrDirtyComponents = new Set<ConcreteComponent>()
export const hmrDirtyComponents = new Map<
ConcreteComponent,
Set<ComponentInternalInstance>
>()

export interface HMRRuntime {
createRecord: typeof createRecord
Expand Down Expand Up @@ -110,18 +113,21 @@ function reload(id: string, newComp: HMRComponent) {
// create a snapshot which avoids the set being mutated during updates
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)

if (!hmrDirtyComponents.has(oldComp)) {
let dirtyInstances = hmrDirtyComponents.get(oldComp)
if (!dirtyInstances) {
// 1. Update existing comp definition to match new one
if (oldComp !== record.initialDef) {
updateComponentDef(oldComp, newComp)
}
// 2. mark definition dirty. This forces the renderer to replace the
// component on patch.
hmrDirtyComponents.add(oldComp)
hmrDirtyComponents.set(oldComp, (dirtyInstances = new Set()))
}
dirtyInstances.add(instance)

// 3. invalidate options resolution cache
instance.appContext.propsCache.delete(instance.type as any)
Expand All @@ -131,18 +137,18 @@ function reload(id: string, newComp: HMRComponent) {
// 4. actually update
if (instance.ceReload) {
// custom element
hmrDirtyComponents.add(oldComp)
dirtyInstances.add(instance)
instance.ceReload((newComp as any).styles)
hmrDirtyComponents.delete(oldComp)
dirtyInstances.delete(instance)
} else if (instance.parent) {
// 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
// don't end up forcing the same parent to re-render multiple times.
instance.parent.effect.dirty = true
queueJob(() => {
instance.parent!.update()
// #6930 avoid infinite recursion
hmrDirtyComponents.delete(oldComp)
// #6930, #11248 avoid infinite recursion
dirtyInstances.delete(instance)
})
} else if (instance.appContext.reload) {
// root instance mounted via createApp() has a reload method
Expand All @@ -159,11 +165,7 @@ function reload(id: string, newComp: HMRComponent) {

// 5. make sure to cleanup dirty hmr components after update
queuePostFlushCb(() => {
for (const instance of instances) {
hmrDirtyComponents.delete(
normalizeClassComponent(instance.type as HMRComponent),
)
}
hmrDirtyComponents.clear()
})
}

Expand Down
21 changes: 10 additions & 11 deletions packages/runtime-core/src/vnode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,17 +387,16 @@ export function isVNode(value: any): value is VNode {
}

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