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
This commit is contained in:
dream-weave
2026-07-31 06:13:10 +08:00
committed by GitHub
parent cb52807b27
commit 32495e40b2
4 changed files with 254 additions and 26 deletions

View File

@@ -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<number>) {
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);
});
});

View File

@@ -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<any>(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?.();
}
};

View File

@@ -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<number>) {
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);
});
});

View File

@@ -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<TParentModalProps extends ModalProps = ModalProps>(
};
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<TParentModalProps extends ModalProps = ModalProps>(
return [Modal, extendedApi as ExtendedModalApi] as const;
}
let injectData = inject<any>(USER_MODAL_INJECT_KEY, {});
// 这个数据已经被使用了说明这个弹窗是嵌套的弹窗不应该merge上层的配置
if (injectData.consumed) {
injectData = {};
} else {
const injectData = inject<any>(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?.();
}
};