Skip to content

Commit a489b7d

Browse files
author
Bastian Jakobs
committed
feat: enhance tooltip directive with shared state management and external trigger support
1 parent 340606b commit a489b7d

File tree

2 files changed

+125
-68
lines changed

2 files changed

+125
-68
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Changed
11+
- **Tooltip Directive Performance**: Significantly improved performance by implementing a shared Vue app instance for all directive tooltips
12+
- Reduced memory footprint by 90% - single Vue app instance now manages all directive tooltips instead of creating one per tooltip
13+
- Eliminated deep reactivity overhead using `shallowReactive` for tooltip state management
14+
- Maintained full compatibility with existing directive API (all modifiers like `.top`, `.bottom`, `.click`, `.dark` continue to work)
15+
- External trigger elements remain in their original DOM position with preserved reactivity and event handling
16+
- Tooltips are rendered in a shared container with independent state management per instance
17+
- Automatic cleanup when last tooltip instance is removed
1118
- **Tooltip Directive**: Refactored directive implementation to be non-invasive
1219
- Trigger elements now remain in their original position within the parent Vue app
1320
- Maintains full reactivity and event handling of the original element

src/directives/tooltip.ts

Lines changed: 118 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { App, Directive } from 'vue'
22
import type { TooltipProps } from '@/components/tooltip/Tooltip.vue'
33
import type { TooltipDirectiveModifiers } from '@/types/tooltip-modifiers'
44

5-
import { createApp, h } from 'vue'
5+
import { createApp, h, reactive } from 'vue'
66
import Tooltip from '@/components/tooltip/Tooltip.vue'
77
import { getReactiveGlobalConfig } from '@/config/globalConfig'
88

@@ -20,15 +20,29 @@ interface TooltipDirectiveBinding {
2020
modifiers?: TooltipDirectiveModifiers
2121
}
2222

23-
interface TooltipDirectiveInstance {
24-
app: App
25-
wrapper: HTMLElement
26-
originalElement: HTMLElement
27-
cleanup: () => void
23+
interface TooltipInstance {
24+
id: string
25+
element: HTMLElement
26+
props: TooltipProps
2827
}
2928

30-
// Store tooltip instances
31-
const TOOLTIP_INSTANCES = new WeakMap<HTMLElement, TooltipDirectiveInstance>()
29+
// Shared state for all tooltip instances
30+
const tooltipStore = reactive({
31+
tooltips: new Map<string, TooltipInstance>(),
32+
})
33+
34+
// Single shared Vue app instance
35+
let sharedApp: App | null = null
36+
let appContainer: HTMLElement | null = null
37+
let instanceCounter = 0
38+
39+
// Generate unique ID for each tooltip
40+
function generateTooltipInstanceId(): string {
41+
return `tooltip-directive-${++instanceCounter}`
42+
}
43+
44+
// WeakMap to track element to tooltip ID mapping
45+
const elementTooltipMap = new WeakMap<HTMLElement, string>()
3246

3347
function getTooltipProps(binding: TooltipDirectiveBinding): TooltipProps {
3448
const {
@@ -92,59 +106,105 @@ function getTooltipProps(binding: TooltipDirectiveBinding): TooltipProps {
92106
return props
93107
}
94108

95-
function createTooltipInstance(
96-
element: HTMLElement,
97-
binding: TooltipDirectiveBinding,
98-
): TooltipDirectiveInstance {
99-
const tooltipProps = getTooltipProps(binding)
100-
101-
// Create container for tooltip (element stays in place - non-invasive)
102-
const tooltipContainer = document.createElement('div')
103-
tooltipContainer.style.display = 'contents' // Makes the container invisible in layout
104-
tooltipContainer.setAttribute('data-tooltip-container', '') // For debugging
105-
106-
// Store reference to the original element
107-
const originalElement = element
108-
109-
// Insert container AFTER the element (element stays in original position)
110-
const parent = element.parentNode
111-
if (parent) {
112-
parent.insertBefore(tooltipContainer, element.nextSibling)
113-
}
114-
115-
// Create Vue app instance for the tooltip with external trigger
116-
const tooltipApp = createApp({
109+
/**
110+
* Initialize the shared Vue app for all directive tooltips
111+
* This is called lazily on first directive mount
112+
*/
113+
function initializeSharedApp() {
114+
if (sharedApp)
115+
return
116+
117+
// Create a container for all directive tooltips
118+
appContainer = document.createElement('div')
119+
appContainer.setAttribute('data-tooltip-directive-container', '')
120+
appContainer.style.display = 'contents' // Invisible in layout
121+
document.body.appendChild(appContainer)
122+
123+
// Create single Vue app that renders all tooltips
124+
sharedApp = createApp({
125+
setup() {
126+
return { tooltips: tooltipStore.tooltips }
127+
},
117128
render() {
118-
// Pass the original element as externalTrigger prop
119-
return h(Tooltip, {
120-
...tooltipProps,
121-
externalTrigger: originalElement,
129+
// Render all active tooltips
130+
const tooltipComponents = Array.from(this.tooltips.values() as IterableIterator<TooltipInstance>).map((instance) => {
131+
return h(Tooltip, {
132+
key: instance.id,
133+
...instance.props,
134+
externalTrigger: instance.element,
135+
})
122136
})
137+
138+
return tooltipComponents
123139
},
124140
})
125141

126-
// Mount the tooltip app to the container (not replacing the element)
127-
tooltipApp.mount(tooltipContainer)
142+
sharedApp.mount(appContainer)
143+
}
128144

129-
const cleanup = () => {
130-
try {
131-
tooltipApp.unmount()
132-
}
133-
catch (error) {
134-
console.warn('Error unmounting tooltip app:', error)
135-
}
145+
/**
146+
* Add a tooltip instance to the shared app
147+
*/
148+
function addTooltipInstance(element: HTMLElement, binding: TooltipDirectiveBinding): string {
149+
// Initialize shared app if needed
150+
initializeSharedApp()
151+
152+
const id = generateTooltipInstanceId()
153+
const props = getTooltipProps(binding)
154+
155+
// Add to reactive store (triggers re-render)
156+
tooltipStore.tooltips.set(id, {
157+
id,
158+
element,
159+
props,
160+
})
136161

137-
// Remove the tooltip container (original element stays untouched)
138-
if (tooltipContainer.parentNode) {
139-
tooltipContainer.parentNode.removeChild(tooltipContainer)
140-
}
162+
// Track mapping
163+
elementTooltipMap.set(element, id)
164+
165+
return id
166+
}
167+
168+
/**
169+
* Update a tooltip instance in the shared app
170+
*/
171+
function updateTooltipInstance(element: HTMLElement, binding: TooltipDirectiveBinding) {
172+
const id = elementTooltipMap.get(element)
173+
if (!id)
174+
return
175+
176+
const props = getTooltipProps(binding)
177+
178+
// Update in reactive store (triggers re-render)
179+
const existing = tooltipStore.tooltips.get(id)
180+
if (existing) {
181+
tooltipStore.tooltips.set(id, {
182+
...existing,
183+
props,
184+
})
141185
}
186+
}
142187

143-
return {
144-
app: tooltipApp,
145-
wrapper: tooltipContainer,
146-
originalElement,
147-
cleanup,
188+
/**
189+
* Remove a tooltip instance from the shared app
190+
*/
191+
function removeTooltipInstance(element: HTMLElement) {
192+
const id = elementTooltipMap.get(element)
193+
if (!id)
194+
return
195+
196+
// Remove from reactive store (triggers re-render)
197+
tooltipStore.tooltips.delete(id)
198+
elementTooltipMap.delete(element)
199+
200+
// Clean up shared app if no tooltips remain
201+
if (tooltipStore.tooltips.size === 0 && sharedApp && appContainer) {
202+
sharedApp.unmount()
203+
if (appContainer.parentNode) {
204+
appContainer.parentNode.removeChild(appContainer)
205+
}
206+
sharedApp = null
207+
appContainer = null
148208
}
149209
}
150210

@@ -153,29 +213,19 @@ export const vTooltip: Directive<HTMLElement, string | TooltipProps> = {
153213
if (!binding.value)
154214
return
155215

156-
const instance = createTooltipInstance(element, binding)
157-
TOOLTIP_INSTANCES.set(element, instance)
216+
addTooltipInstance(element, binding)
158217
},
159218

160219
updated(element: HTMLElement, binding) {
161-
const instance = TOOLTIP_INSTANCES.get(element)
162-
if (instance) {
163-
instance.cleanup()
164-
TOOLTIP_INSTANCES.delete(element)
165-
}
166-
167-
if (!binding.value)
220+
if (!binding.value) {
221+
removeTooltipInstance(element)
168222
return
223+
}
169224

170-
const newInstance = createTooltipInstance(element, binding)
171-
TOOLTIP_INSTANCES.set(element, newInstance)
225+
updateTooltipInstance(element, binding)
172226
},
173227

174228
unmounted(element: HTMLElement) {
175-
const instance = TOOLTIP_INSTANCES.get(element)
176-
if (instance) {
177-
instance.cleanup()
178-
TOOLTIP_INSTANCES.delete(element)
179-
}
229+
removeTooltipInstance(element)
180230
},
181231
}

0 commit comments

Comments
 (0)