diff --git a/packages/dockview-vue/src/__tests__/keepalive.spec.ts b/packages/dockview-vue/src/__tests__/keepalive.spec.ts new file mode 100644 index 000000000..3b0c8fde3 --- /dev/null +++ b/packages/dockview-vue/src/__tests__/keepalive.spec.ts @@ -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 `` (rendered by ``), 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; + viewProps: Record; + addPanel: (api: any) => void; +} + +/** Wrap any view component in `` behind a `show` toggle. */ +function createHost(view: ReturnType, 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 (issue #1369)', () => { + let wrapper: ReturnType; + + 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; + + 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'); + }); +}); diff --git a/packages/dockview-vue/src/__tests__/teleport-render.spec.ts b/packages/dockview-vue/src/__tests__/teleport-render.spec.ts new file mode 100644 index 000000000..bc0fe05b8 --- /dev/null +++ b/packages/dockview-vue/src/__tests__/teleport-render.spec.ts @@ -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; + + afterEach(() => { + wrapper?.unmount(); + }); + + function mountDockview(components: Record) { + 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' + ); + }); +}); diff --git a/packages/dockview-vue/src/composables/useViewComponent.ts b/packages/dockview-vue/src/composables/useViewComponent.ts index 963f3c5c7..3cd57502a 100644 --- a/packages/dockview-vue/src/composables/useViewComponent.ts +++ b/packages/dockview-vue/src/composables/useViewComponent.ts @@ -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, @@ -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[]; @@ -61,6 +62,13 @@ export function useViewComponent< const instance = ref(null); const eventDisposables: DockviewIDisposable[] = []; + /** + * Components are teleported into the view's DOM (rendered by + * `` 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], @@ -99,7 +107,8 @@ export function useViewComponent< options.id, options.name, component! as any, - inst + inst, + registry ); }, } as unknown as Partial); @@ -131,7 +140,8 @@ export function useViewComponent< options.id, options.name, component! as any, - inst + inst, + registry ); }, } as TFrameworkOptions; @@ -164,5 +174,6 @@ export function useViewComponent< return { el, instance, + registry, }; } diff --git a/packages/dockview-vue/src/dockview/dockview.vue b/packages/dockview-vue/src/dockview/dockview.vue index 2cfd9bc17..5ea3ddded 100644 --- a/packages/dockview-vue/src/dockview/dockview.vue +++ b/packages/dockview-vue/src/dockview/dockview.vue @@ -21,10 +21,12 @@ import { VueContextMenuItemRenderer, VueTabGroupChipRenderer, VueRenderer, + VueRendererRegistry, VueWatermarkRenderer, findComponent, resolveComponent, } from '../utils'; +import DockviewPortals from '../dockviewPortals.vue'; import type { IDockviewVueProps, VueEvents } from './types'; const DEFAULT_VUE_TAB = 'props.defaultTabComponent'; @@ -61,6 +63,13 @@ PROPERTY_KEYS_DOCKVIEW.forEach((coreOptionKey) => { const inst = getCurrentInstance()!; +/** + * Components are mounted into dockview's DOM via `` (rendered by + * `` below) rather than detached `render()` roots, keeping + * panels in the Vue component tree. See {@link VueRendererRegistry}. + */ +const registry = new VueRendererRegistry(); + watch( () => props.tabGroupChipComponent, (newValue) => { @@ -69,7 +78,11 @@ watch( createTabGroupChipComponent: newValue ? () => { const component = resolveComponent(newValue, inst); - return new VueTabGroupChipRenderer(component!, inst); + return new VueTabGroupChipRenderer( + component!, + inst, + registry + ); } : undefined, }); @@ -87,7 +100,8 @@ watch( const component = resolveComponent(newValue, inst); return new VueGroupDragGhostRenderer( component!, - inst + inst, + registry ); } : undefined, @@ -123,7 +137,7 @@ watch( } if (component) { - return new VueRenderer(component, inst); + return new VueRenderer(component, inst, registry); } return undefined; }, @@ -140,7 +154,11 @@ watch( createWatermarkComponent: newValue ? () => { const component = resolveComponent(newValue, inst); - return new VueWatermarkRenderer(component!, inst); + return new VueWatermarkRenderer( + component!, + inst, + registry + ); } : undefined, }); @@ -159,7 +177,8 @@ watch( return new VueHeaderActionsRenderer( component!, inst, - group + group, + registry ); } : undefined, @@ -179,7 +198,8 @@ watch( return new VueHeaderActionsRenderer( component!, inst, - group + group, + registry ); } : undefined, @@ -199,7 +219,8 @@ watch( return new VueHeaderActionsRenderer( component!, inst, - group + group, + registry ); } : undefined, @@ -224,7 +245,7 @@ onMounted(() => { options.name, props.components ); - return new VueRenderer(component!, inst); + return new VueRenderer(component!, inst, registry); }, createTabComponent(options) { let component = @@ -238,7 +259,7 @@ onMounted(() => { } if (component) { - return new VueRenderer(component, inst); + return new VueRenderer(component, inst, registry); } return undefined; }, @@ -249,7 +270,7 @@ onMounted(() => { inst ); - return new VueWatermarkRenderer(component!, inst); + return new VueWatermarkRenderer(component!, inst, registry); } : undefined, createLeftHeaderActionComponent: props.leftHeaderActionsComponent @@ -258,7 +279,12 @@ onMounted(() => { props.leftHeaderActionsComponent, inst ); - return new VueHeaderActionsRenderer(component!, inst, group); + return new VueHeaderActionsRenderer( + component!, + inst, + group, + registry + ); } : undefined, createPrefixHeaderActionComponent: props.prefixHeaderActionsComponent @@ -267,7 +293,12 @@ onMounted(() => { props.prefixHeaderActionsComponent, inst ); - return new VueHeaderActionsRenderer(component!, inst, group); + return new VueHeaderActionsRenderer( + component!, + inst, + group, + registry + ); } : undefined, createRightHeaderActionComponent: props.rightHeaderActionsComponent @@ -276,7 +307,12 @@ onMounted(() => { props.rightHeaderActionsComponent, inst ); - return new VueHeaderActionsRenderer(component!, inst, group); + return new VueHeaderActionsRenderer( + component!, + inst, + group, + registry + ); } : undefined, createContextMenuItemComponent: (options) => { @@ -288,7 +324,7 @@ onMounted(() => { options.component as string, props.components ); - return new VueContextMenuItemRenderer(component!, inst); + return new VueContextMenuItemRenderer(component!, inst, registry); }, }; @@ -304,7 +340,7 @@ onMounted(() => { const chipValue = props.tabGroupChipComponent; coreOptions.createTabGroupChipComponent = () => { const component = resolveComponent(chipValue, inst); - return new VueTabGroupChipRenderer(component!, inst); + return new VueTabGroupChipRenderer(component!, inst, registry); }; } @@ -312,7 +348,7 @@ onMounted(() => { const ghostValue = props.groupDragGhostComponent; coreOptions.createGroupDragGhostComponent = () => { const component = resolveComponent(ghostValue, inst); - return new VueGroupDragGhostRenderer(component!, inst); + return new VueGroupDragGhostRenderer(component!, inst, registry); }; } @@ -358,4 +394,5 @@ onBeforeUnmount(() => { diff --git a/packages/dockview-vue/src/dockviewPortals.vue b/packages/dockview-vue/src/dockviewPortals.vue new file mode 100644 index 000000000..60d168784 --- /dev/null +++ b/packages/dockview-vue/src/dockviewPortals.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/dockview-vue/src/gridview/gridview.vue b/packages/dockview-vue/src/gridview/gridview.vue index 68e053261..81d98a626 100644 --- a/packages/dockview-vue/src/gridview/gridview.vue +++ b/packages/dockview-vue/src/gridview/gridview.vue @@ -8,6 +8,7 @@ import { } from 'dockview'; import { useViewComponent } from '../composables/useViewComponent'; import { VueGridviewPanelView } from './view'; +import DockviewPortals from '../dockviewPortals.vue'; import type { IGridviewVueProps, GridviewVueEvents } from './types'; function extractCoreOptions(props: IGridviewVueProps): GridviewOptions { @@ -24,13 +25,13 @@ function extractCoreOptions(props: IGridviewVueProps): GridviewOptions { const emit = defineEmits(); const props = defineProps(); -const { el } = useViewComponent( +const { el, registry } = useViewComponent( { componentName: 'gridview-vue', propertyKeys: PROPERTY_KEYS_GRIDVIEW, createApi: createGridview, - createView: (id, name, component, instance) => - new VueGridviewPanelView(id, name, component, instance), + createView: (id, name, component, instance, registry) => + new VueGridviewPanelView(id, name, component, instance, registry), extractCoreOptions, }, props, @@ -40,4 +41,5 @@ const { el } = useViewComponent( diff --git a/packages/dockview-vue/src/gridview/view.ts b/packages/dockview-vue/src/gridview/view.ts index c9a0a8c8a..2e837a5d2 100644 --- a/packages/dockview-vue/src/gridview/view.ts +++ b/packages/dockview-vue/src/gridview/view.ts @@ -1,6 +1,6 @@ import { GridviewApi, GridviewPanel, IFrameworkPart } from 'dockview'; import { type ComponentInternalInstance } from 'vue'; -import { VuePart, type VueComponent } from '../utils'; +import { VuePart, VueRendererRegistry, type VueComponent } from '../utils'; import type { IGridviewVuePanelProps } from './types'; export class VueGridviewPanelView extends GridviewPanel { @@ -8,17 +8,24 @@ export class VueGridviewPanelView extends GridviewPanel { id: string, component: string, private readonly vueComponent: VueComponent, - private readonly parent: ComponentInternalInstance + private readonly parent: ComponentInternalInstance, + private readonly registry?: VueRendererRegistry ) { super(id, component); } getComponent(): IFrameworkPart { - const part = new VuePart(this.element, this.vueComponent, this.parent, { - params: this._params?.params ?? {}, - api: this.api, - containerApi: new GridviewApi((this._params as any).accessor), - }); + const part = new VuePart( + this.element, + this.vueComponent, + this.parent, + { + params: this._params?.params ?? {}, + api: this.api, + containerApi: new GridviewApi((this._params as any).accessor), + }, + this.registry + ); part.init(); return part; } diff --git a/packages/dockview-vue/src/paneview/paneview.vue b/packages/dockview-vue/src/paneview/paneview.vue index e0759f509..ec9f0beff 100644 --- a/packages/dockview-vue/src/paneview/paneview.vue +++ b/packages/dockview-vue/src/paneview/paneview.vue @@ -8,6 +8,7 @@ import { } from 'dockview'; import { useViewComponent } from '../composables/useViewComponent'; import { VuePaneviewPanelView } from './view'; +import DockviewPortals from '../dockviewPortals.vue'; import type { IPaneviewVueProps, PaneviewVueEvents } from './types'; function extractCoreOptions(props: IPaneviewVueProps): PaneviewOptions { @@ -24,13 +25,13 @@ function extractCoreOptions(props: IPaneviewVueProps): PaneviewOptions { const emit = defineEmits(); const props = defineProps(); -const { el } = useViewComponent( +const { el, registry } = useViewComponent( { componentName: 'paneview-vue', propertyKeys: PROPERTY_KEYS_PANEVIEW, createApi: createPaneview, - createView: (id, name, component, instance) => - new VuePaneviewPanelView(id, component, instance), + createView: (id, name, component, instance, registry) => + new VuePaneviewPanelView(id, component, instance, registry), extractCoreOptions, onApiCreated: (api) => [ api.onDidDrop((event) => emit('didDrop', event)), @@ -43,4 +44,5 @@ const { el } = useViewComponent( diff --git a/packages/dockview-vue/src/paneview/view.ts b/packages/dockview-vue/src/paneview/view.ts index 2f6be3c51..72ce2fde3 100644 --- a/packages/dockview-vue/src/paneview/view.ts +++ b/packages/dockview-vue/src/paneview/view.ts @@ -4,7 +4,7 @@ import { PanelUpdateEvent, } from 'dockview'; import { type ComponentInternalInstance } from 'vue'; -import { VuePart, type VueComponent } from '../utils'; +import { VuePart, VueRendererRegistry, type VueComponent } from '../utils'; import type { IPaneviewVuePanelProps } from './types'; export class VuePaneviewPanelView implements IPanePart { @@ -18,7 +18,8 @@ export class VuePaneviewPanelView implements IPanePart { constructor( public readonly id: string, private readonly vueComponent: VueComponent, - private readonly parent: ComponentInternalInstance + private readonly parent: ComponentInternalInstance, + private readonly registry?: VueRendererRegistry ) { this._element = document.createElement('div'); this._element.style.height = '100%'; @@ -26,12 +27,18 @@ export class VuePaneviewPanelView implements IPanePart { } public init(parameters: PanePanelComponentInitParameter): void { - this.part = new VuePart(this.element, this.vueComponent, this.parent, { - params: parameters.params, - api: parameters.api, - title: parameters.title, - containerApi: parameters.containerApi, - }); + this.part = new VuePart( + this.element, + this.vueComponent, + this.parent, + { + params: parameters.params, + api: parameters.api, + title: parameters.title, + containerApi: parameters.containerApi, + }, + this.registry + ); this.part.init(); } diff --git a/packages/dockview-vue/src/splitview/splitview.vue b/packages/dockview-vue/src/splitview/splitview.vue index 93d0bc2b4..d544bc397 100644 --- a/packages/dockview-vue/src/splitview/splitview.vue +++ b/packages/dockview-vue/src/splitview/splitview.vue @@ -8,6 +8,7 @@ import { } from 'dockview'; import { useViewComponent } from '../composables/useViewComponent'; import { VueSplitviewPanelView } from './view'; +import DockviewPortals from '../dockviewPortals.vue'; import type { ISplitviewVueProps, SplitviewVueEvents } from './types'; function extractCoreOptions(props: ISplitviewVueProps): SplitviewOptions { @@ -24,13 +25,13 @@ function extractCoreOptions(props: ISplitviewVueProps): SplitviewOptions { const emit = defineEmits(); const props = defineProps(); -const { el } = useViewComponent( +const { el, registry } = useViewComponent( { componentName: 'splitview-vue', propertyKeys: PROPERTY_KEYS_SPLITVIEW, createApi: createSplitview, - createView: (id, name, component, instance) => - new VueSplitviewPanelView(id, name, component, instance), + createView: (id, name, component, instance, registry) => + new VueSplitviewPanelView(id, name, component, instance, registry), extractCoreOptions, }, props, @@ -40,4 +41,5 @@ const { el } = useViewComponent( diff --git a/packages/dockview-vue/src/splitview/view.ts b/packages/dockview-vue/src/splitview/view.ts index a97303d75..6f060bb38 100644 --- a/packages/dockview-vue/src/splitview/view.ts +++ b/packages/dockview-vue/src/splitview/view.ts @@ -5,7 +5,7 @@ import { IFrameworkPart, } from 'dockview'; import { type ComponentInternalInstance } from 'vue'; -import { VuePart, type VueComponent } from '../utils'; +import { VuePart, VueRendererRegistry, type VueComponent } from '../utils'; import type { ISplitviewVuePanelProps } from './types'; export class VueSplitviewPanelView extends SplitviewPanel { @@ -13,17 +13,24 @@ export class VueSplitviewPanelView extends SplitviewPanel { id: string, component: string, private readonly vueComponent: VueComponent, - private readonly parent: ComponentInternalInstance + private readonly parent: ComponentInternalInstance, + private readonly registry?: VueRendererRegistry ) { super(id, component); } getComponent(): IFrameworkPart { - const part = new VuePart(this.element, this.vueComponent, this.parent, { - params: this._params?.params ?? {}, - api: this.api, - containerApi: new SplitviewApi((this._params as any).accessor), - }); + const part = new VuePart( + this.element, + this.vueComponent, + this.parent, + { + params: this._params?.params ?? {}, + api: this.api, + containerApi: new SplitviewApi((this._params as any).accessor), + }, + this.registry + ); part.init(); return part; } diff --git a/packages/dockview-vue/src/utils.ts b/packages/dockview-vue/src/utils.ts index 51b888744..f778ce278 100644 --- a/packages/dockview-vue/src/utils.ts +++ b/packages/dockview-vue/src/utils.ts @@ -31,6 +31,10 @@ import { type ComponentOptionsBase, render, cloneVNode, + markRaw, + shallowReactive, + shallowRef, + type ShallowRef, type DefineComponent, type ComponentInternalInstance, } from 'vue'; @@ -124,8 +128,70 @@ export function mountVueComponent>( }; } +export interface VueMountDisposable { + update: (props: Record) => void; + dispose: () => void; +} + +/** + * A single component to be teleported by the host's ``. + * + * `props` is a {@link ShallowRef} so reassigning it triggers a re-render + * without Vue deeply proxying the value — the params object carries raw + * dockview API instances that must NOT be made reactive. + */ +export interface VueMountEntry { + readonly id: number; + readonly component: VueComponent; + readonly target: HTMLElement; + readonly props: ShallowRef>; +} + +let nextMountEntryId = 0; + +/** + * Shared, reactive registry of components that a host (`dockview.vue`, + * `splitview.vue`, ...) renders via `` instead of the detached + * `render()` root used by {@link mountVueComponent}. + * + * Teleporting keeps each panel a true descendant of the host in the Vue + * component tree, so framework features that walk the tree work natively: + * KeepAlive (`onActivated`/`onDeactivated`), `provide`/`inject`, `` + * and error boundaries. + */ +export class VueRendererRegistry { + readonly entries = shallowReactive([]); + + mount( + component: VueComponent, + target: HTMLElement, + props: Record + ): VueMountDisposable { + const entry: VueMountEntry = { + id: nextMountEntryId++, + component: markRaw(component), + target, + props: shallowRef(props), + }; + this.entries.push(entry); + + return { + update: (newProps: Record) => { + entry.props.value = { ...entry.props.value, ...newProps }; + }, + dispose: () => { + const index = this.entries.indexOf(entry); + if (index !== -1) { + this.entries.splice(index, 1); + } + }, + }; + } +} + abstract class AbstractVueRenderer { protected readonly _element: HTMLElement; + protected _renderDisposable: VueMountDisposable | undefined; get element(): HTMLElement { return this._element; @@ -133,22 +199,38 @@ abstract class AbstractVueRenderer { constructor( protected readonly component: VueComponent, - protected readonly parent: ComponentInternalInstance + protected readonly parent: ComponentInternalInstance, + protected readonly registry?: VueRendererRegistry ) { this._element = document.createElement('div'); this.element.className = 'dv-vue-part'; this.element.style.height = '100%'; this.element.style.width = '100%'; } + + /** + * Mount `component` into `this.element`. When a {@link VueRendererRegistry} + * is provided the component is teleported by the host (keeping it in the + * Vue component tree); otherwise it falls back to the detached + * {@link mountVueComponent} render root. + */ + protected mount(props: Record): void { + this._renderDisposable?.dispose(); + this._renderDisposable = this.registry + ? this.registry.mount(this.component, this.element, props) + : mountVueComponent( + this.component, + this.parent, + props, + this.element + ); + } } export class VueRenderer extends AbstractVueRenderer implements ITabRenderer, IContentRenderer { - private _renderDisposable: - | { update: (props: any) => void; dispose: () => void } - | undefined; private _api: DockviewPanelApi | undefined; private _containerApi: DockviewApi | undefined; @@ -163,13 +245,7 @@ export class VueRenderer tabLocation: parameters.tabLocation, }; - this._renderDisposable?.dispose(); - this._renderDisposable = mountVueComponent( - this.component, - this.parent, - { params: props }, - this.element - ); + this.mount({ params: props }); } update(event: PanelUpdateEvent): void { @@ -196,10 +272,6 @@ export class VueWatermarkRenderer extends AbstractVueRenderer implements IWatermarkRenderer { - private _renderDisposable: - | { update: (props: any) => void; dispose: () => void } - | undefined; - get element(): HTMLElement { return this._element; } @@ -210,13 +282,7 @@ export class VueWatermarkRenderer containerApi: parameters.containerApi, }; - this._renderDisposable?.dispose(); - this._renderDisposable = mountVueComponent( - this.component, - this.parent, - { params: props }, - this.element - ); + this.mount({ params: props }); } update(event: PanelUpdateEvent): void { @@ -232,9 +298,6 @@ export class VueHeaderActionsRenderer extends AbstractVueRenderer implements IHeaderActionsRenderer { - private _renderDisposable: - | { update: (props: any) => void; dispose: () => void } - | undefined; private readonly _mutableDisposable = new DockviewMutableDisposable(); private _baseProps: IGroupHeaderProps | undefined; @@ -245,9 +308,10 @@ export class VueHeaderActionsRenderer constructor( component: VueComponent, parent: ComponentInternalInstance, - private readonly group: DockviewGroupPanel + private readonly group: DockviewGroupPanel, + registry?: VueRendererRegistry ) { - super(component, parent); + super(component, parent, registry); } init(props: IGroupHeaderProps): void { @@ -271,13 +335,7 @@ export class VueHeaderActionsRenderer }) ); - this._renderDisposable?.dispose(); - this._renderDisposable = mountVueComponent( - this.component, - this.parent, - { params: this.buildEnrichedProps() }, - this.element - ); + this.mount({ params: this.buildEnrichedProps() }); } dispose(): void { @@ -310,18 +368,8 @@ export class VueContextMenuItemRenderer extends AbstractVueRenderer implements IContextMenuItemRenderer { - private _renderDisposable: - | { update: (props: any) => void; dispose: () => void } - | undefined; - init(props: IContextMenuItemComponentProps): void { - this._renderDisposable?.dispose(); - this._renderDisposable = mountVueComponent( - this.component, - this.parent, - { params: props }, - this.element - ); + this.mount({ params: props }); } dispose(): void { @@ -333,34 +381,28 @@ export class VueTabGroupChipRenderer extends AbstractVueRenderer implements ITabGroupChipRenderer { - private _renderDisposable: - | { update: (props: any) => void; dispose: () => void } - | undefined; - get element(): HTMLElement { return this._element; } - constructor(component: VueComponent, parent: ComponentInternalInstance) { - super(component, parent); + constructor( + component: VueComponent, + parent: ComponentInternalInstance, + registry?: VueRendererRegistry + ) { + super(component, parent, registry); this.element.style.height = ''; this.element.style.width = ''; this.element.style.display = 'inline-flex'; } init(params: { tabGroup: ITabGroup; api: DockviewApi }): void { - this._renderDisposable?.dispose(); - this._renderDisposable = mountVueComponent( - this.component, - this.parent, - { - params: { - tabGroup: params.tabGroup, - api: params.api, - }, + this.mount({ + params: { + tabGroup: params.tabGroup, + api: params.api, }, - this.element - ); + }); } update(params: { tabGroup: ITabGroup }): void { @@ -378,30 +420,24 @@ export class VueGroupDragGhostRenderer extends AbstractVueRenderer implements IGroupDragGhostRenderer { - private _renderDisposable: - | { update: (props: any) => void; dispose: () => void } - | undefined; - - constructor(component: VueComponent, parent: ComponentInternalInstance) { - super(component, parent); + constructor( + component: VueComponent, + parent: ComponentInternalInstance, + registry?: VueRendererRegistry + ) { + super(component, parent, registry); this.element.style.height = ''; this.element.style.width = ''; this.element.style.display = 'inline-flex'; } init(params: { group: IDockviewGroupPanel; api: DockviewApi }): void { - this._renderDisposable?.dispose(); - this._renderDisposable = mountVueComponent( - this.component, - this.parent, - { - params: { - group: params.group, - api: params.api, - }, + this.mount({ + params: { + group: params.group, + api: params.api, }, - this.element - ); + }); } dispose(): void { @@ -410,25 +446,26 @@ export class VueGroupDragGhostRenderer } export class VuePart = any> { - private _renderDisposable: - | { update: (props: any) => void; dispose: () => void } - | undefined; + private _renderDisposable: VueMountDisposable | undefined; constructor( private readonly element: HTMLElement, private readonly vueComponent: VueComponent, private readonly parent: ComponentInternalInstance, - private props: T + private props: T, + private readonly registry?: VueRendererRegistry ) {} init(): void { this._renderDisposable?.dispose(); - this._renderDisposable = mountVueComponent( - this.vueComponent, - this.parent, - this.props, - this.element - ); + this._renderDisposable = this.registry + ? this.registry.mount(this.vueComponent, this.element, this.props) + : mountVueComponent( + this.vueComponent, + this.parent, + this.props, + this.element + ); } update(props: T): void {