Skip to content

Commit c8bc464

Browse files
authored
fix: prevent panel unmount on extended tab inactivity (#135)
1 parent a4241b9 commit c8bc464

File tree

1 file changed

+303
-10
lines changed

1 file changed

+303
-10
lines changed

src/panel.ts

Lines changed: 303 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,68 @@ class LiebePanel extends HTMLElement {
1919
private _hass: HomeAssistant | null = null
2020
private root?: ReactDOM.Root
2121
private initialized = false
22+
private instanceId = Math.random().toString(36).substr(2, 9)
23+
private visibilityHandler?: () => void
24+
private beforeUnloadHandler?: () => void
25+
private keepAliveInterval?: number
26+
private lastInteraction = Date.now()
27+
private parentObserver?: MutationObserver
28+
private reconnectCheckInterval?: number
29+
private lastParentElement?: Element | null
30+
31+
constructor() {
32+
super()
33+
console.log(`[Liebe Panel ${this.instanceId}] Constructor called`)
34+
35+
// Prevent panel from being garbage collected
36+
;(window as unknown as { __liebePanel?: LiebePanel }).__liebePanel = this
37+
38+
// Override remove method to prevent removal
39+
const originalRemove = this.remove
40+
this.remove = () => {
41+
console.log(`[Liebe Panel ${this.instanceId}] remove() called - ignoring if we have hass`)
42+
if (!this._hass) {
43+
originalRemove.call(this)
44+
}
45+
}
46+
47+
// Set up mutation observer to detect removal attempts
48+
this.setupParentObserver()
49+
}
2250

2351
set hass(hass: HomeAssistant) {
52+
console.log(`[Liebe Panel ${this.instanceId}] hass setter called`, {
53+
hasHass: !!hass,
54+
initialized: this.initialized,
55+
connected: this.isConnected,
56+
})
2457
this._hass = hass
2558
this.render()
2659
}
2760

2861
connectedCallback() {
29-
// Only initialize once - don't recreate on reconnection
30-
if (!this.initialized) {
62+
console.log(`[Liebe Panel ${this.instanceId}] connectedCallback called`, {
63+
initialized: this.initialized,
64+
hasHass: !!this._hass,
65+
isConnected: this.isConnected,
66+
hasShadowRoot: !!this.shadowRoot,
67+
})
68+
69+
// Check if we need to re-initialize (e.g., if shadow root was lost)
70+
const needsInit = !this.initialized || !this.shadowRoot || !this.root
71+
72+
if (needsInit) {
73+
console.log(`[Liebe Panel ${this.instanceId}] Initializing/Re-initializing panel`)
3174
this.initialized = true
3275

33-
const shadow = this.attachShadow({ mode: 'open' })
76+
// Create or get shadow root
77+
const shadow = this.shadowRoot || this.attachShadow({ mode: 'open' })
78+
79+
// Clear shadow root if re-initializing
80+
if (this.shadowRoot && shadow.childNodes.length > 0) {
81+
shadow.innerHTML = ''
82+
}
83+
3484
const container = document.createElement('div')
3585
container.style.height = '100%'
3686
shadow.appendChild(container)
@@ -52,28 +102,271 @@ class LiebePanel extends HTMLElement {
52102
}
53103

54104
this.root = ReactDOM.createRoot(container)
105+
106+
// Track visibility changes
107+
this.visibilityHandler = () => {
108+
console.log(`[Liebe Panel ${this.instanceId}] Visibility changed`, {
109+
hidden: document.hidden,
110+
visibilityState: document.visibilityState,
111+
timestamp: new Date().toISOString(),
112+
isConnected: this.isConnected,
113+
})
114+
115+
// Update interaction time when page becomes visible
116+
if (!document.hidden) {
117+
this.lastInteraction = Date.now()
118+
this.startKeepAlive()
119+
120+
// If we're not connected but should be, try to reconnect
121+
if (!this.isConnected && this._hass && this.lastParentElement) {
122+
console.log(`[Liebe Panel ${this.instanceId}] Attempting to reconnect to parent`)
123+
try {
124+
this.lastParentElement.appendChild(this)
125+
} catch (error) {
126+
console.error(`[Liebe Panel ${this.instanceId}] Failed to reconnect:`, error)
127+
}
128+
}
129+
}
130+
}
131+
document.addEventListener('visibilitychange', this.visibilityHandler)
132+
133+
// Track page unload
134+
this.beforeUnloadHandler = () => {
135+
console.log(`[Liebe Panel ${this.instanceId}] Page unloading`)
136+
}
137+
window.addEventListener('beforeunload', this.beforeUnloadHandler)
138+
139+
// Start keep-alive mechanism
140+
this.startKeepAlive()
55141
}
56142

143+
// Store parent element reference
144+
if (this.parentNode) {
145+
this.lastParentElement = this.parentNode as Element
146+
}
147+
148+
// Set up parent observer if not already set
149+
if (!this.parentObserver && this.parentNode) {
150+
this.setupParentObserver()
151+
}
152+
153+
// Start reconnect check
154+
this.startReconnectCheck()
155+
57156
this.render()
58157
}
59158

60159
disconnectedCallback() {
61-
// Do NOT unmount or cleanup - Home Assistant will re-add this element
160+
console.log(`[Liebe Panel ${this.instanceId}] disconnectedCallback called`, {
161+
initialized: this.initialized,
162+
hasHass: !!this._hass,
163+
timestamp: new Date().toISOString(),
164+
documentHidden: document.hidden,
165+
visibilityState: document.visibilityState,
166+
timeSinceLastInteraction: Date.now() - this.lastInteraction,
167+
parentElement: this.lastParentElement?.tagName,
168+
})
169+
170+
// Do NOT stop keep-alive or cleanup - we want to stay ready for reconnection
171+
// Only clean up if we're truly being destroyed (no hass object)
172+
if (!this._hass) {
173+
this.stopKeepAlive()
174+
this.stopReconnectCheck()
175+
this.cleanupParentObserver()
176+
}
177+
178+
// Do NOT unmount or cleanup React - Home Assistant will re-add this element
62179
// when the user navigates back to the panel
63180
}
64181

182+
private startKeepAlive() {
183+
// Clear any existing interval
184+
this.stopKeepAlive()
185+
186+
// Set up a keep-alive mechanism that periodically touches the DOM
187+
// to prevent Home Assistant from removing the panel
188+
this.keepAliveInterval = window.setInterval(() => {
189+
if (this.isConnected) {
190+
// Touch a DOM property to keep the element "active"
191+
void this.offsetHeight
192+
193+
// Periodically re-render if we have hass object
194+
if (this._hass && this.root) {
195+
const timeSinceInteraction = Date.now() - this.lastInteraction
196+
// Only re-render if page has been inactive for more than 5 minutes
197+
if (timeSinceInteraction > 5 * 60 * 1000) {
198+
console.log(`[Liebe Panel ${this.instanceId}] Keep-alive render triggered`)
199+
this.render()
200+
this.lastInteraction = Date.now()
201+
}
202+
}
203+
}
204+
}, 30000) // Check every 30 seconds
205+
}
206+
207+
private stopKeepAlive() {
208+
if (this.keepAliveInterval) {
209+
clearInterval(this.keepAliveInterval)
210+
this.keepAliveInterval = undefined
211+
}
212+
}
213+
214+
private setupParentObserver() {
215+
// Watch for removal from parent
216+
this.parentObserver = new MutationObserver((mutations) => {
217+
mutations.forEach((mutation) => {
218+
if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
219+
mutation.removedNodes.forEach((node) => {
220+
if (
221+
node === this ||
222+
(node.nodeType === Node.ELEMENT_NODE && (node as Element).contains(this))
223+
) {
224+
console.log(
225+
`[Liebe Panel ${this.instanceId}] Detected removal from parent, attempting to prevent`
226+
)
227+
// If we're being removed and we have a parent, try to add ourselves back
228+
if (mutation.target && this._hass) {
229+
// Store the parent for reconnection attempts
230+
this.lastParentElement = mutation.target as Element
231+
232+
setTimeout(() => {
233+
if (!this.isConnected && mutation.target) {
234+
console.log(`[Liebe Panel ${this.instanceId}] Re-adding panel to parent`)
235+
try {
236+
mutation.target.appendChild(this)
237+
} catch (error) {
238+
console.error(
239+
`[Liebe Panel ${this.instanceId}] Failed to re-add to parent:`,
240+
error
241+
)
242+
}
243+
}
244+
}, 0)
245+
}
246+
}
247+
})
248+
}
249+
})
250+
})
251+
252+
// Observe multiple levels up the DOM tree
253+
if (this.parentNode) {
254+
this.parentObserver.observe(this.parentNode, { childList: true, subtree: true })
255+
256+
// Also observe the document body for more aggressive monitoring
257+
if (document.body && !document.body.contains(this)) {
258+
this.parentObserver.observe(document.body, { childList: true, subtree: true })
259+
}
260+
}
261+
}
262+
263+
private cleanupParentObserver() {
264+
if (this.parentObserver) {
265+
this.parentObserver.disconnect()
266+
this.parentObserver = undefined
267+
}
268+
}
269+
270+
private startReconnectCheck() {
271+
// Clear any existing interval
272+
this.stopReconnectCheck()
273+
274+
// Check periodically if we need to reconnect
275+
this.reconnectCheckInterval = window.setInterval(() => {
276+
if (!this.isConnected && this._hass && this.lastParentElement && !document.hidden) {
277+
console.log(`[Liebe Panel ${this.instanceId}] Reconnect check: attempting to reconnect`)
278+
279+
// Try to find the panel container in Home Assistant
280+
const panelContainer =
281+
document.querySelector('partial-panel-resolver') ||
282+
document.querySelector('[id^="panel-"]') ||
283+
this.lastParentElement
284+
285+
if (panelContainer && !panelContainer.contains(this)) {
286+
try {
287+
panelContainer.appendChild(this)
288+
console.log(
289+
`[Liebe Panel ${this.instanceId}] Successfully reconnected to panel container`
290+
)
291+
} catch (error) {
292+
console.error(
293+
`[Liebe Panel ${this.instanceId}] Failed to reconnect during check:`,
294+
error
295+
)
296+
}
297+
}
298+
}
299+
}, 5000) // Check every 5 seconds
300+
}
301+
302+
private stopReconnectCheck() {
303+
if (this.reconnectCheckInterval) {
304+
clearInterval(this.reconnectCheckInterval)
305+
this.reconnectCheckInterval = undefined
306+
}
307+
}
308+
65309
private render() {
310+
console.log(`[Liebe Panel ${this.instanceId}] render called`, {
311+
hasRoot: !!this.root,
312+
hasHass: !!this._hass,
313+
initialized: this.initialized,
314+
})
315+
66316
if (!this.root || !this._hass) return
67-
this.root.render(
68-
React.createElement(
69-
React.StrictMode,
70-
null,
71-
React.createElement(Provider, { hass: this._hass }, React.createElement(PanelApp))
317+
318+
try {
319+
this.root.render(
320+
React.createElement(
321+
React.StrictMode,
322+
null,
323+
React.createElement(Provider, { hass: this._hass }, React.createElement(PanelApp))
324+
)
72325
)
73-
)
326+
} catch (error) {
327+
console.error(`[Liebe Panel ${this.instanceId}] Render error:`, error)
328+
}
74329
}
75330
}
76331

77332
// Register custom element with environment-specific name
78333
const panelConfig = getPanelConfig()
79334
customElements.define(panelConfig.elementName, LiebePanel)
335+
336+
// Global panel guardian - ensures panel stays connected
337+
let globalPanelCheck: number | undefined
338+
const startGlobalPanelGuardian = () => {
339+
if (globalPanelCheck) return
340+
341+
globalPanelCheck = window.setInterval(() => {
342+
const panel = (window as unknown as { __liebePanel?: LiebePanel }).__liebePanel
343+
if (panel && !panel.isConnected && document.visibilityState === 'visible') {
344+
console.log(
345+
'[Global Panel Guardian] Panel disconnected while page visible, attempting recovery'
346+
)
347+
348+
// Try to find where the panel should be
349+
const possibleContainers = [
350+
document.querySelector('partial-panel-resolver'),
351+
document.querySelector('[id^="panel-"]'),
352+
document.querySelector('ha-panel-iframe'),
353+
document.querySelector('.view'),
354+
].filter(Boolean)
355+
356+
for (const container of possibleContainers) {
357+
if (container && !container.contains(panel)) {
358+
try {
359+
container.appendChild(panel)
360+
console.log('[Global Panel Guardian] Successfully restored panel to container')
361+
break
362+
} catch (error) {
363+
// Continue trying other containers
364+
}
365+
}
366+
}
367+
}
368+
}, 10000) // Check every 10 seconds
369+
}
370+
371+
// Start the guardian
372+
startGlobalPanelGuardian()

0 commit comments

Comments
 (0)