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,
|
defineComponent,
|
||||||
h,
|
h,
|
||||||
inject,
|
inject,
|
||||||
|
markRaw,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
provide,
|
provide,
|
||||||
reactive,
|
|
||||||
ref,
|
ref,
|
||||||
|
shallowReactive,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
import { usePreferences } from '@vben-core/preferences';
|
import { usePreferences } from '@vben-core/preferences';
|
||||||
@@ -45,16 +47,17 @@ export function useVbenDrawer<
|
|||||||
};
|
};
|
||||||
const { connectedComponent } = options;
|
const { connectedComponent } = options;
|
||||||
if (connectedComponent) {
|
if (connectedComponent) {
|
||||||
const extendedApi = reactive({});
|
const extendedApi = shallowReactive({});
|
||||||
const isDrawerReady = ref(true);
|
const isDrawerReady = ref(true);
|
||||||
const Drawer = defineComponent(
|
const Drawer = defineComponent(
|
||||||
(props: TParentDrawerProps, { attrs, slots }) => {
|
(props: TParentDrawerProps, { attrs, slots }) => {
|
||||||
|
function rebindApi(api: ExtendedDrawerApi) {
|
||||||
|
Object.setPrototypeOf(extendedApi, markRaw(api));
|
||||||
|
}
|
||||||
|
|
||||||
provide(USER_DRAWER_INJECT_KEY, {
|
provide(USER_DRAWER_INJECT_KEY, {
|
||||||
extendApi(api: ExtendedDrawerApi) {
|
extendApi: rebindApi,
|
||||||
// 不能直接给 reactive 赋值,会丢失响应
|
consumed: false,
|
||||||
// 不能用 Object.assign,会丢失 api 的原型函数
|
|
||||||
Object.setPrototypeOf(extendedApi, api);
|
|
||||||
},
|
|
||||||
options: defaultOptions,
|
options: defaultOptions,
|
||||||
async reCreateDrawer() {
|
async reCreateDrawer() {
|
||||||
isDrawerReady.value = false;
|
isDrawerReady.value = false;
|
||||||
@@ -85,22 +88,37 @@ export function useVbenDrawer<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const injectData = inject<any>(USER_DRAWER_INJECT_KEY, {});
|
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 = {
|
const mergedOptions = {
|
||||||
...DEFAULT_DRAWER_PROPS,
|
...DEFAULT_DRAWER_PROPS,
|
||||||
...injectData.options,
|
...effectiveOptions,
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
} as DrawerApiOptions;
|
} as DrawerApiOptions;
|
||||||
|
|
||||||
mergedOptions.onOpenChange = (isOpen: boolean) => {
|
mergedOptions.onOpenChange = (isOpen: boolean) => {
|
||||||
options.onOpenChange?.(isOpen);
|
options.onOpenChange?.(isOpen);
|
||||||
injectData.options?.onOpenChange?.(isOpen);
|
if (!isConsumed) {
|
||||||
|
injectData.options?.onOpenChange?.(isOpen);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClosed = mergedOptions.onClosed;
|
const onClosed = mergedOptions.onClosed;
|
||||||
mergedOptions.onClosed = () => {
|
mergedOptions.onClosed = () => {
|
||||||
onClosed?.();
|
onClosed?.();
|
||||||
if (mergedOptions.destroyOnClose) {
|
if (mergedOptions.destroyOnClose && !isConsumed) {
|
||||||
|
if (injectData.consumed !== undefined) {
|
||||||
|
injectData.consumed = false;
|
||||||
|
}
|
||||||
injectData.reCreateDrawer?.();
|
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,
|
defineComponent,
|
||||||
h,
|
h,
|
||||||
inject,
|
inject,
|
||||||
|
markRaw,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
provide,
|
provide,
|
||||||
reactive,
|
|
||||||
ref,
|
ref,
|
||||||
|
shallowReactive,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
import { usePreferences } from '@vben-core/preferences';
|
import { usePreferences } from '@vben-core/preferences';
|
||||||
@@ -40,16 +42,16 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
|
|||||||
};
|
};
|
||||||
const { connectedComponent } = options;
|
const { connectedComponent } = options;
|
||||||
if (connectedComponent) {
|
if (connectedComponent) {
|
||||||
const extendedApi = reactive({});
|
const extendedApi = shallowReactive({});
|
||||||
const isModalReady = ref(true);
|
const isModalReady = ref(true);
|
||||||
const Modal = defineComponent(
|
const Modal = defineComponent(
|
||||||
(props: TParentModalProps, { attrs, slots }) => {
|
(props: TParentModalProps, { attrs, slots }) => {
|
||||||
|
function rebindApi(api: ExtendedModalApi) {
|
||||||
|
Object.setPrototypeOf(extendedApi, markRaw(api));
|
||||||
|
}
|
||||||
|
|
||||||
provide(USER_MODAL_INJECT_KEY, {
|
provide(USER_MODAL_INJECT_KEY, {
|
||||||
extendApi(api: ExtendedModalApi) {
|
extendApi: rebindApi,
|
||||||
// 不能直接给 reactive 赋值,会丢失响应
|
|
||||||
// 不能用 Object.assign,会丢失 api 的原型函数
|
|
||||||
Object.setPrototypeOf(extendedApi, api);
|
|
||||||
},
|
|
||||||
consumed: false,
|
consumed: false,
|
||||||
options: defaultOptions,
|
options: defaultOptions,
|
||||||
async reCreateModal() {
|
async reCreateModal() {
|
||||||
@@ -83,30 +85,38 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
|
|||||||
return [Modal, extendedApi as ExtendedModalApi] as const;
|
return [Modal, extendedApi as ExtendedModalApi] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
let injectData = inject<any>(USER_MODAL_INJECT_KEY, {});
|
const injectData = inject<any>(USER_MODAL_INJECT_KEY, {});
|
||||||
// 这个数据已经被使用了,说明这个弹窗是嵌套的弹窗,不应该merge上层的配置
|
const isConsumed = injectData.consumed;
|
||||||
if (injectData.consumed) {
|
const effectiveOptions = isConsumed ? {} : injectData.options;
|
||||||
injectData = {};
|
if (!isConsumed && injectData.consumed !== undefined) {
|
||||||
} else {
|
|
||||||
injectData.consumed = true;
|
injectData.consumed = true;
|
||||||
}
|
}
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (!isConsumed && injectData.consumed !== undefined) {
|
||||||
|
injectData.consumed = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const mergedOptions = {
|
const mergedOptions = {
|
||||||
...DEFAULT_MODAL_PROPS,
|
...DEFAULT_MODAL_PROPS,
|
||||||
...injectData.options,
|
...effectiveOptions,
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
} as ModalApiOptions;
|
} as ModalApiOptions;
|
||||||
|
|
||||||
mergedOptions.onOpenChange = (isOpen: boolean) => {
|
mergedOptions.onOpenChange = (isOpen: boolean) => {
|
||||||
options.onOpenChange?.(isOpen);
|
options.onOpenChange?.(isOpen);
|
||||||
injectData.options?.onOpenChange?.(isOpen);
|
if (!isConsumed) {
|
||||||
|
injectData.options?.onOpenChange?.(isOpen);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClosed = mergedOptions.onClosed;
|
const onClosed = mergedOptions.onClosed;
|
||||||
mergedOptions.onClosed = () => {
|
mergedOptions.onClosed = () => {
|
||||||
onClosed?.();
|
onClosed?.();
|
||||||
if (mergedOptions.destroyOnClose) {
|
if (mergedOptions.destroyOnClose && !isConsumed) {
|
||||||
injectData.consumed = false;
|
if (injectData.consumed !== undefined) {
|
||||||
|
injectData.consumed = false;
|
||||||
|
}
|
||||||
injectData.reCreateModal?.();
|
injectData.reCreateModal?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user