diff --git a/packages/runtime-core/__tests__/hmr.spec.ts b/packages/runtime-core/__tests__/hmr.spec.ts index 39aece16a5a..ba9c7c0780b 100644 --- a/packages/runtime-core/__tests__/hmr.spec.ts +++ b/packages/runtime-core/__tests__/hmr.spec.ts @@ -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() @@ -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(`
0
0`) + expect(serializeInner(root)).toBe(`
0
0
0`) reload(childId, { __hmrId: childId, @@ -458,9 +465,9 @@ describe('hot module replacement', () => { render: compileToFunction(`
{{ count }}
`), }) await nextTick() - expect(serializeInner(root)).toBe(`
1
1`) - expect(unmountSpy).toHaveBeenCalledTimes(1) - expect(mountSpy).toHaveBeenCalledTimes(1) + expect(serializeInner(root)).toBe(`
1
1
1`) + expect(unmountSpy).toHaveBeenCalledTimes(2) + expect(mountSpy).toHaveBeenCalledTimes(2) }) // #1156 - static nodes should retain DOM element reference across updates @@ -805,4 +812,43 @@ describe('hot module replacement', () => { `
1

3

1

3

2

`, ) }) + + // #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(`
{{ count }}
`), + } + 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(`
0
0
`) + + // change count to 1 + reload(childId, { + __hmrId: childId, + data() { + return { count: 1 } + }, + render: compileToFunction(`
{{ count }}
`), + }) + + await timeout() + + expect(serializeInner(root)).toBe(`
1
1
`) + }) }) diff --git a/packages/runtime-core/src/hmr.ts b/packages/runtime-core/src/hmr.ts index 8196eb89195..5a4a95705b0 100644 --- a/packages/runtime-core/src/hmr.ts +++ b/packages/runtime-core/src/hmr.ts @@ -14,7 +14,10 @@ type HMRComponent = ComponentOptions | ClassComponent export let isHmrUpdating = false -export const hmrDirtyComponents = new Set() +export const hmrDirtyComponents = new Map< + ConcreteComponent, + Set +>() export interface HMRRuntime { createRecord: typeof createRecord @@ -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) @@ -131,9 +137,9 @@ 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 @@ -141,8 +147,8 @@ function reload(id: string, newComp: HMRComponent) { 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 @@ -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() }) } diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 2210440e717..a0d4074aaea 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -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 }