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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user