Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions packages/dockview-vue/src/__tests__/keepalive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import {
defineComponent,
h,
inject,
KeepAlive,
onActivated,
onDeactivated,
nextTick,
provide,
} from 'vue';
import DockviewVue from '../dockview/dockview.vue';
import SplitviewVue from '../splitview/splitview.vue';
import GridviewVue from '../gridview/gridview.vue';
import PaneviewVue from '../paneview/paneview.vue';
import { Orientation } from 'dockview';

/**
* Regression coverage for https://github.com/mathuo/dockview/issues/1369
*
* Panels are mounted via `<Teleport>` (rendered by `<DockviewPortals>`), which
* keeps them as real descendants of the host component in the Vue component
* tree. That ancestry is what lets framework features that walk the tree reach
* panels: KeepAlive (`onActivated`/`onDeactivated`) and `provide`/`inject`.
*
* Before the teleport migration, panels were detached `render()` roots with no
* tree ancestry, so KeepAlive could never reach them and these hooks never ran.
*/

interface ViewCase {
name: string;
view: ReturnType<typeof defineComponent>;
viewProps: Record<string, any>;
addPanel: (api: any) => void;
}

/** Wrap any view component in `<keep-alive>` behind a `show` toggle. */
function createHost(view: ReturnType<typeof defineComponent>, viewProps: any) {
return defineComponent({
name: 'KeepAliveHost',
props: { show: { type: Boolean, default: true } },
emits: ['ready'],
setup(props, { emit }) {
return () =>
h(KeepAlive, null, {
default: () =>
props.show
? h(view, {
...viewProps,
onReady: (event: { api: any }) =>
emit('ready', event),
})
: null,
});
},
});
}

function makePanel(
cssClass: string,
hooks?: { activated?: () => void; deactivated?: () => void }
) {
return defineComponent({
name: `Panel_${cssClass}`,
props: ['params', 'api', 'containerApi', 'title'],
setup() {
if (hooks?.activated) {
onActivated(hooks.activated);
}
if (hooks?.deactivated) {
onDeactivated(hooks.deactivated);
}
return () => h('div', { class: cssClass }, 'Panel');
},
});
}

describe('Vue components under <keep-alive> (issue #1369)', () => {
let wrapper: ReturnType<typeof mount>;

afterEach(() => {
wrapper?.unmount();
});

const cases: ViewCase[] = [
{
name: 'DockviewVue',
view: DockviewVue,
viewProps: {},
addPanel: (api) =>
api.addPanel({ id: 'p1', component: 'Panel', title: 'P1' }),
},
{
name: 'SplitviewVue',
view: SplitviewVue,
viewProps: { orientation: Orientation.HORIZONTAL },
addPanel: (api) => api.addPanel({ id: 'p1', component: 'Panel' }),
},
{
name: 'GridviewVue',
view: GridviewVue,
viewProps: {},
addPanel: (api) => api.addPanel({ id: 'p1', component: 'Panel' }),
},
{
name: 'PaneviewVue',
view: PaneviewVue,
viewProps: {},
addPanel: (api) =>
api.addPanel({ id: 'p1', component: 'Panel', title: 'P1' }),
},
];

for (const c of cases) {
test(`${c.name}: panel renders into the DOM via teleport`, async () => {
const cssClass = `teleport-${c.name}`;
const Panel = makePanel(cssClass);

wrapper = mount(
createHost(c.view, { ...c.viewProps, components: { Panel } }),
{ attachTo: document.body, props: { show: true } }
);
await flushPromises();

const api = (wrapper.emitted('ready')![0][0] as any).api;
c.addPanel(api);
await flushPromises();
await nextTick();

expect(document.querySelector(`.${cssClass}`)).not.toBeNull();
});

test(`${c.name}: onActivated/onDeactivated fire across keep-alive toggles`, async () => {
const activated = vi.fn();
const deactivated = vi.fn();
const Panel = makePanel(`ka-${c.name}`, { activated, deactivated });

wrapper = mount(
createHost(c.view, { ...c.viewProps, components: { Panel } }),
{ attachTo: document.body, props: { show: true } }
);
await flushPromises();

const api = (wrapper.emitted('ready')![0][0] as any).api;
c.addPanel(api);
await flushPromises();
await nextTick();

// Deactivate (e.g. router navigates away): the panel is cached, not
// unmounted, so onDeactivated fires. This was entirely missing
// before the teleport migration.
await wrapper.setProps({ show: false });
await flushPromises();
expect(deactivated).toHaveBeenCalledTimes(1);

// Reactivate: the cached panel is re-inserted and onActivated fires.
await wrapper.setProps({ show: true });
await flushPromises();
expect(activated).toHaveBeenCalledTimes(1);

// The cycle keeps working on subsequent toggles.
await wrapper.setProps({ show: false });
await flushPromises();
expect(deactivated).toHaveBeenCalledTimes(2);

await wrapper.setProps({ show: true });
await flushPromises();
expect(activated).toHaveBeenCalledTimes(2);
});
}
});

describe('provide/inject reaches teleported panels', () => {
let wrapper: ReturnType<typeof mount>;

afterEach(() => {
wrapper?.unmount();
});

test('a value provided above the host is injectable inside a panel', async () => {
const injected = vi.fn();

const Panel = defineComponent({
name: 'InjectPanel',
props: ['params'],
setup() {
injected(inject('shared-token'));
return () => h('div', { class: 'inject-panel' }, 'Panel');
},
});

// The host provides a value; the panel — a teleported descendant —
// must resolve it through the component tree.
const Host = defineComponent({
name: 'ProvideHost',
emits: ['ready'],
setup(_, { emit }) {
provide('shared-token', 'from-host');
return () =>
h(DockviewVue, {
components: { Panel },
onReady: (event: { api: any }) => emit('ready', event),
});
},
});

wrapper = mount(Host, { attachTo: document.body });
await flushPromises();

const api = (wrapper.emitted('ready')![0][0] as any).api;
api.addPanel({ id: 'p1', component: 'Panel', title: 'P1' });
await flushPromises();
await nextTick();

expect(document.querySelector('.inject-panel')).not.toBeNull();
expect(injected).toHaveBeenCalledWith('from-host');
});
});
118 changes: 118 additions & 0 deletions packages/dockview-vue/src/__tests__/teleport-render.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { defineComponent, h, nextTick, onMounted, onUnmounted } from 'vue';
import DockviewVue from '../dockview/dockview.vue';

/**
* Render-behaviour coverage for the teleport mount path beyond the happy
* single-panel case: the `onlyWhenVisible` detach/reattach cycle (tab
* switching) and panel prop updates flowing through the reactive props ref.
*/
describe('teleport render behaviour', () => {
let wrapper: ReturnType<typeof mount>;

afterEach(() => {
wrapper?.unmount();
});

function mountDockview(components: Record<string, any>) {
return mount(DockviewVue, {
props: { components },
attachTo: document.body,
});
}

test('tab switching detaches/reattaches without destroying the panel instance', async () => {
const mounted = vi.fn();
const unmounted = vi.fn();

const Panel = defineComponent({
name: 'TabPanel',
props: ['params'],
setup(props) {
onMounted(mounted);
onUnmounted(unmounted);
return () =>
h(
'div',
{ class: `tab-panel-${props.params.params.which}` },
'Panel'
);
},
});

wrapper = mountDockview({ Panel });
await flushPromises();

const api = (wrapper.emitted('ready')![0][0] as any).api;
api.addPanel({
id: 'p1',
component: 'Panel',
params: { which: '1' },
});
api.addPanel({
id: 'p2',
component: 'Panel',
params: { which: '2' },
position: { referencePanel: 'p1', direction: 'within' },
});
await flushPromises();
await nextTick();

// Two panels in one group -> two component instances, both kept alive.
expect(mounted).toHaveBeenCalledTimes(2);
expect(unmounted).not.toHaveBeenCalled();

// Switch active tab back to p1 a few times; the default 'onlyWhenVisible'
// renderer detaches/reattaches the teleport target element. The Vue
// instances must survive (no remount, no unmount).
api.getPanel('p1')!.api.setActive();
await flushPromises();
api.getPanel('p2')!.api.setActive();
await flushPromises();
api.getPanel('p1')!.api.setActive();
await flushPromises();
await nextTick();

expect(mounted).toHaveBeenCalledTimes(2);
expect(unmounted).not.toHaveBeenCalled();
expect(document.querySelector('.tab-panel-1')).not.toBeNull();
});

test('panel param updates flow through to the rendered component', async () => {
const Panel = defineComponent({
name: 'UpdatePanel',
props: ['params'],
setup: (props) => () =>
h(
'div',
{ class: 'update-panel' },
String(props.params.params.value)
),
});

wrapper = mountDockview({ Panel });
await flushPromises();

const api = (wrapper.emitted('ready')![0][0] as any).api;
api.addPanel({
id: 'p1',
component: 'Panel',
params: { value: 'before' },
});
await flushPromises();
await nextTick();

expect(document.querySelector('.update-panel')!.textContent).toBe(
'before'
);

api.getPanel('p1')!.api.updateParameters({ value: 'after' });
await flushPromises();
await nextTick();

expect(document.querySelector('.update-panel')!.textContent).toBe(
'after'
);
});
});
19 changes: 15 additions & 4 deletions packages/dockview-vue/src/composables/useViewComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type ComponentInternalInstance,
} from 'vue';
import type { DockviewIDisposable } from 'dockview';
import { findComponent } from '../utils';
import { findComponent, VueRendererRegistry } from '../utils';

export interface ViewComponentConfig<
TApi,
Expand All @@ -28,7 +28,8 @@ export interface ViewComponentConfig<
id: string,
name: string | undefined,
component: any,
instance: ComponentInternalInstance
instance: ComponentInternalInstance,
registry: VueRendererRegistry
) => TView;
extractCoreOptions: (props: TProps) => TOptions;
onApiCreated?: (api: TApi) => DockviewIDisposable[];
Expand Down Expand Up @@ -61,6 +62,13 @@ export function useViewComponent<
const instance = ref<TApi | null>(null);
const eventDisposables: DockviewIDisposable[] = [];

/**
* Components are teleported into the view's DOM (rendered by
* `<DockviewPortals>` in the host template) so panels stay in the Vue
* component tree. See {@link VueRendererRegistry}.
*/
const registry = new VueRendererRegistry();

config.propertyKeys.forEach((coreOptionKey) => {
watch(
() => (props as any)[coreOptionKey],
Expand Down Expand Up @@ -99,7 +107,8 @@ export function useViewComponent<
options.id,
options.name,
component! as any,
inst
inst,
registry
);
},
} as unknown as Partial<TOptions>);
Expand Down Expand Up @@ -131,7 +140,8 @@ export function useViewComponent<
options.id,
options.name,
component! as any,
inst
inst,
registry
);
},
} as TFrameworkOptions;
Expand Down Expand Up @@ -164,5 +174,6 @@ export function useViewComponent<
return {
el,
instance,
registry,
};
}
Loading
Loading