From 32495e40b2d2adca452d29617685eeaf3bfb128f Mon Sep 17 00:00:00 2001 From: dream-weave <62940878+dream-weave@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:13:10 +0800 Subject: [PATCH] fix(@vben-core/popup-ui): rebind popup api after HMR (#8217) * fix(@vben-core/popup-ui): rebind popup api after hmr * fix(@vben-core/popup-ui): preserve options after remount --- .../src/drawer/__tests__/use-drawer.test.ts | 100 ++++++++++++++++++ .../ui-kit/popup-ui/src/drawer/use-drawer.ts | 38 +++++-- .../src/modal/__tests__/use-modal.test.ts | 100 ++++++++++++++++++ .../ui-kit/popup-ui/src/modal/use-modal.ts | 42 +++++--- 4 files changed, 254 insertions(+), 26 deletions(-) create mode 100644 packages/@core/ui-kit/popup-ui/src/drawer/__tests__/use-drawer.test.ts create mode 100644 packages/@core/ui-kit/popup-ui/src/modal/__tests__/use-modal.test.ts diff --git a/packages/@core/ui-kit/popup-ui/src/drawer/__tests__/use-drawer.test.ts b/packages/@core/ui-kit/popup-ui/src/drawer/__tests__/use-drawer.test.ts new file mode 100644 index 00000000..9e40fa28 --- /dev/null +++ b/packages/@core/ui-kit/popup-ui/src/drawer/__tests__/use-drawer.test.ts @@ -0,0 +1,100 @@ +import type { App, Ref } from 'vue'; + +import type { ExtendedDrawerApi } from '../drawer'; + +import { createApp, defineComponent, h, nextTick, ref } from 'vue'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useVbenDrawer } from '../use-drawer'; + +vi.mock('@vben-core/preferences', () => ({ + usePreferences: () => ({ + globalEscapeShortcutKey: { value: true }, + }), +})); + +vi.mock('../drawer.vue', () => ({ + default: { + name: 'VbenDrawerStub', + render: () => null, + }, +})); + +let activeApp: App | undefined; + +async function mountRebindingHarness() { + const consumerKey = ref(0); + let currentApi: ExtendedDrawerApi | undefined; + const onOpenChange = vi.fn(); + + const Consumer = defineComponent(() => { + const [Drawer, drawerApi] = useVbenDrawer(); + currentApi = drawerApi; + return () => h(Drawer); + }); + + const ConnectedDrawer = defineComponent(() => { + return () => h(Consumer, { key: consumerKey.value }); + }); + + const [ParentDrawer, parentApi] = useVbenDrawer({ + connectedComponent: ConnectedDrawer, + onOpenChange, + title: 'Parent drawer title', + }); + const host = document.createElement('div'); + document.body.append(host); + + activeApp = createApp(() => h(ParentDrawer)); + activeApp.mount(host); + await nextTick(); + + return { + consumerKey, + getCurrentApi: () => currentApi, + onOpenChange, + parentApi, + }; +} + +async function remountConsumer(consumerKey: Ref) { + consumerKey.value += 1; + await nextTick(); +} + +afterEach(() => { + activeApp?.unmount(); + activeApp = undefined; + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('useVbenDrawer', () => { + it('rebinds the parent api when the consumer is recreated', async () => { + const { consumerKey, getCurrentApi, onOpenChange, parentApi } = + await mountRebindingHarness(); + const initialApi = getCurrentApi(); + + expect(initialApi).toBeDefined(); + if (!initialApi) return; + expect(parentApi.store).toBe(initialApi.store); + + await remountConsumer(consumerKey); + const recreatedApi = getCurrentApi(); + + expect(recreatedApi).toBeDefined(); + if (!recreatedApi) return; + expect(recreatedApi).not.toBe(initialApi); + expect(recreatedApi.store.state.title).toBe('Parent drawer title'); + expect(parentApi.store).toBe(recreatedApi.store); + + parentApi.open(); + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(recreatedApi.store.state.isOpen).toBe(true); + expect(initialApi.store.state.isOpen).toBe(false); + + await parentApi.close(); + expect(recreatedApi.store.state.isOpen).toBe(false); + }); +}); diff --git a/packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts b/packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts index 46ae4fd5..6e391954 100644 --- a/packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts +++ b/packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts @@ -8,10 +8,12 @@ import { defineComponent, h, inject, + markRaw, nextTick, + onBeforeUnmount, provide, - reactive, ref, + shallowReactive, } from 'vue'; import { usePreferences } from '@vben-core/preferences'; @@ -45,16 +47,17 @@ export function useVbenDrawer< }; const { connectedComponent } = options; if (connectedComponent) { - const extendedApi = reactive({}); + const extendedApi = shallowReactive({}); const isDrawerReady = ref(true); const Drawer = defineComponent( (props: TParentDrawerProps, { attrs, slots }) => { + function rebindApi(api: ExtendedDrawerApi) { + Object.setPrototypeOf(extendedApi, markRaw(api)); + } + provide(USER_DRAWER_INJECT_KEY, { - extendApi(api: ExtendedDrawerApi) { - // 不能直接给 reactive 赋值,会丢失响应 - // 不能用 Object.assign,会丢失 api 的原型函数 - Object.setPrototypeOf(extendedApi, api); - }, + extendApi: rebindApi, + consumed: false, options: defaultOptions, async reCreateDrawer() { isDrawerReady.value = false; @@ -85,22 +88,37 @@ export function useVbenDrawer< } const injectData = inject(USER_DRAWER_INJECT_KEY, {}); + const isConsumed = injectData.consumed; + const effectiveOptions = isConsumed ? {} : injectData.options; + if (!isConsumed && injectData.consumed !== undefined) { + injectData.consumed = true; + } + onBeforeUnmount(() => { + if (!isConsumed && injectData.consumed !== undefined) { + injectData.consumed = false; + } + }); const mergedOptions = { ...DEFAULT_DRAWER_PROPS, - ...injectData.options, + ...effectiveOptions, ...defaultOptions, } as DrawerApiOptions; mergedOptions.onOpenChange = (isOpen: boolean) => { options.onOpenChange?.(isOpen); - injectData.options?.onOpenChange?.(isOpen); + if (!isConsumed) { + injectData.options?.onOpenChange?.(isOpen); + } }; const onClosed = mergedOptions.onClosed; mergedOptions.onClosed = () => { onClosed?.(); - if (mergedOptions.destroyOnClose) { + if (mergedOptions.destroyOnClose && !isConsumed) { + if (injectData.consumed !== undefined) { + injectData.consumed = false; + } injectData.reCreateDrawer?.(); } }; diff --git a/packages/@core/ui-kit/popup-ui/src/modal/__tests__/use-modal.test.ts b/packages/@core/ui-kit/popup-ui/src/modal/__tests__/use-modal.test.ts new file mode 100644 index 00000000..4a60cbe6 --- /dev/null +++ b/packages/@core/ui-kit/popup-ui/src/modal/__tests__/use-modal.test.ts @@ -0,0 +1,100 @@ +import type { App, Ref } from 'vue'; + +import type { ExtendedModalApi } from '../modal'; + +import { createApp, defineComponent, h, nextTick, ref, toRaw } from 'vue'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useVbenModal } from '../use-modal'; + +vi.mock('@vben-core/preferences', () => ({ + usePreferences: () => ({ + globalEscapeShortcutKey: { value: true }, + }), +})); + +vi.mock('../modal.vue', () => ({ + default: { + name: 'VbenModalStub', + render: () => null, + }, +})); + +let activeApp: App | undefined; + +async function mountRebindingHarness() { + const consumerKey = ref(0); + let currentApi: ExtendedModalApi | undefined; + const onOpenChange = vi.fn(); + + const Consumer = defineComponent(() => { + const [Modal, modalApi] = useVbenModal(); + currentApi = modalApi; + return () => h(Modal); + }); + + const ConnectedModal = defineComponent(() => { + return () => h(Consumer, { key: consumerKey.value }); + }); + + const [ParentModal, parentApi] = useVbenModal({ + connectedComponent: ConnectedModal, + onOpenChange, + title: 'Parent modal title', + }); + const host = document.createElement('div'); + document.body.append(host); + + activeApp = createApp(() => h(ParentModal)); + activeApp.mount(host); + await nextTick(); + + return { + consumerKey, + getCurrentApi: () => currentApi, + onOpenChange, + parentApi, + }; +} + +async function remountConsumer(consumerKey: Ref) { + consumerKey.value += 1; + await nextTick(); +} + +afterEach(() => { + activeApp?.unmount(); + activeApp = undefined; + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('useVbenModal', () => { + it('rebinds the parent api when the consumer is recreated', async () => { + const { consumerKey, getCurrentApi, onOpenChange, parentApi } = + await mountRebindingHarness(); + const initialApi = getCurrentApi(); + + expect(initialApi).toBeDefined(); + if (!initialApi) return; + expect(toRaw(parentApi.store)).toBe(initialApi.store); + + await remountConsumer(consumerKey); + const recreatedApi = getCurrentApi(); + + expect(recreatedApi).toBeDefined(); + if (!recreatedApi) return; + expect(recreatedApi).not.toBe(initialApi); + expect(recreatedApi.store.state.title).toBe('Parent modal title'); + expect(toRaw(parentApi.store)).toBe(recreatedApi.store); + + parentApi.open(); + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(recreatedApi.store.state.isOpen).toBe(true); + expect(initialApi.store.state.isOpen).toBe(false); + + await parentApi.close(); + expect(recreatedApi.store.state.isOpen).toBe(false); + }); +}); diff --git a/packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts b/packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts index 87456a16..2b818465 100644 --- a/packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts +++ b/packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts @@ -4,10 +4,12 @@ import { defineComponent, h, inject, + markRaw, nextTick, + onBeforeUnmount, provide, - reactive, ref, + shallowReactive, } from 'vue'; import { usePreferences } from '@vben-core/preferences'; @@ -40,16 +42,16 @@ export function useVbenModal( }; const { connectedComponent } = options; if (connectedComponent) { - const extendedApi = reactive({}); + const extendedApi = shallowReactive({}); const isModalReady = ref(true); const Modal = defineComponent( (props: TParentModalProps, { attrs, slots }) => { + function rebindApi(api: ExtendedModalApi) { + Object.setPrototypeOf(extendedApi, markRaw(api)); + } + provide(USER_MODAL_INJECT_KEY, { - extendApi(api: ExtendedModalApi) { - // 不能直接给 reactive 赋值,会丢失响应 - // 不能用 Object.assign,会丢失 api 的原型函数 - Object.setPrototypeOf(extendedApi, api); - }, + extendApi: rebindApi, consumed: false, options: defaultOptions, async reCreateModal() { @@ -83,30 +85,38 @@ export function useVbenModal( return [Modal, extendedApi as ExtendedModalApi] as const; } - let injectData = inject(USER_MODAL_INJECT_KEY, {}); - // 这个数据已经被使用了,说明这个弹窗是嵌套的弹窗,不应该merge上层的配置 - if (injectData.consumed) { - injectData = {}; - } else { + const injectData = inject(USER_MODAL_INJECT_KEY, {}); + const isConsumed = injectData.consumed; + const effectiveOptions = isConsumed ? {} : injectData.options; + if (!isConsumed && injectData.consumed !== undefined) { injectData.consumed = true; } + onBeforeUnmount(() => { + if (!isConsumed && injectData.consumed !== undefined) { + injectData.consumed = false; + } + }); const mergedOptions = { ...DEFAULT_MODAL_PROPS, - ...injectData.options, + ...effectiveOptions, ...defaultOptions, } as ModalApiOptions; mergedOptions.onOpenChange = (isOpen: boolean) => { options.onOpenChange?.(isOpen); - injectData.options?.onOpenChange?.(isOpen); + if (!isConsumed) { + injectData.options?.onOpenChange?.(isOpen); + } }; const onClosed = mergedOptions.onClosed; mergedOptions.onClosed = () => { onClosed?.(); - if (mergedOptions.destroyOnClose) { - injectData.consumed = false; + if (mergedOptions.destroyOnClose && !isConsumed) { + if (injectData.consumed !== undefined) { + injectData.consumed = false; + } injectData.reCreateModal?.(); } };