fix: vue mounting logic with tests (#1651)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* Bug Fixes
* Prevents duplicate modal instances and remounts, improving stability
across pages.
* Improves auto-mount reliability with better DOM validation and
recovery from mount errors.
* Enhances cleanup during unmounts to avoid residual artifacts and
intermittent UI issues.
* More robust handling of shadow DOM and problematic DOM structures,
reducing crashes.

* Style
* Adds extra top margin to the OS version controls for improved spacing.

* Tests
* Introduces a comprehensive test suite covering mounting, unmounting,
error recovery, i18n, and global state behaviors.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-09-03 17:10:21 -04:00
committed by GitHub
parent 88087d5201
commit 33774aa596
3 changed files with 1216 additions and 20 deletions

View File

@@ -0,0 +1,864 @@
import { defineComponent, h } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MockInstance } from 'vitest';
import type { App as VueApp } from 'vue';
// Extend HTMLElement to include Vue's internal properties (matching the source file)
interface HTMLElementWithVue extends HTMLElement {
__vueParentComponent?: {
appContext?: {
app?: VueApp;
};
};
}
// We'll manually mock createApp only in specific tests that need it
vi.mock('vue', async () => {
const actual = await vi.importActual<typeof import('vue')>('vue');
return {
...actual,
};
});
const mockEnsureTeleportContainer = vi.fn();
vi.mock('@unraid/ui', () => ({
ensureTeleportContainer: mockEnsureTeleportContainer,
}));
const mockI18n = {
global: {},
install: vi.fn(),
};
vi.mock('vue-i18n', () => ({
createI18n: vi.fn(() => mockI18n),
}));
const mockApolloClient = { query: vi.fn(), mutate: vi.fn() };
vi.mock('~/helpers/create-apollo-client', () => ({
client: mockApolloClient,
}));
const mockGlobalPinia = {
install: vi.fn(),
use: vi.fn(),
_a: null,
_e: null,
_s: new Map(),
state: {},
};
vi.mock('~/store/globalPinia', () => ({
globalPinia: mockGlobalPinia,
}));
vi.mock('~/locales/en_US.json', () => ({
default: { test: 'Test Message' },
}));
vi.mock('~/helpers/i18n-utils', () => ({
createHtmlEntityDecoder: vi.fn(() => (str: string) => str),
}));
vi.mock('~/assets/main.css?inline', () => ({
default: '.test { color: red; }',
}));
describe('vue-mount-app', () => {
let mountVueApp: typeof import('~/components/Wrapper/vue-mount-app').mountVueApp;
let unmountVueApp: typeof import('~/components/Wrapper/vue-mount-app').unmountVueApp;
let getMountedApp: typeof import('~/components/Wrapper/vue-mount-app').getMountedApp;
let autoMountComponent: typeof import('~/components/Wrapper/vue-mount-app').autoMountComponent;
let TestComponent: ReturnType<typeof defineComponent>;
let consoleWarnSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let consoleInfoSpy: MockInstance;
let consoleDebugSpy: MockInstance;
let testContainer: HTMLDivElement;
beforeEach(async () => {
const module = await import('~/components/Wrapper/vue-mount-app');
mountVueApp = module.mountVueApp;
unmountVueApp = module.unmountVueApp;
getMountedApp = module.getMountedApp;
autoMountComponent = module.autoMountComponent;
TestComponent = defineComponent({
name: 'TestComponent',
props: {
message: {
type: String,
default: 'Hello',
},
},
setup(props) {
return () => h('div', { class: 'test-component' }, props.message);
},
});
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
testContainer = document.createElement('div');
testContainer.id = 'test-container';
document.body.appendChild(testContainer);
vi.clearAllMocks();
// Clear mounted apps from previous tests
if (window.mountedApps) {
window.mountedApps.clear();
}
});
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
if (window.mountedApps) {
window.mountedApps.clear();
}
});
describe('mountVueApp', () => {
it('should mount a Vue app to a single element', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(element.querySelector('.test-component')).toBeTruthy();
expect(element.textContent).toBe('Hello');
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
});
it('should mount with custom props', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
props: { message: 'Custom Message' },
});
expect(app).toBeTruthy();
expect(element.textContent).toBe('Custom Message');
});
it('should parse props from element attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.setAttribute('message', 'Attribute Message');
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(element.textContent).toBe('Attribute Message');
});
it('should parse JSON props from attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.setAttribute('message', '{"text": "JSON Message"}');
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(element.getAttribute('message')).toBe('{"text": "JSON Message"}');
});
it('should handle HTML-encoded JSON in attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.setAttribute('message', '{&quot;text&quot;: &quot;Encoded&quot;}');
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(element.getAttribute('message')).toBe('{&quot;text&quot;: &quot;Encoded&quot;}');
});
it('should mount to multiple elements', () => {
const element1 = document.createElement('div');
element1.className = 'multi-mount';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi-mount';
document.body.appendChild(element2);
const app = mountVueApp({
component: TestComponent,
selector: '.multi-mount',
});
expect(app).toBeTruthy();
expect(element1.querySelector('.test-component')).toBeTruthy();
expect(element2.querySelector('.test-component')).toBeTruthy();
});
it('should use shadow root when specified', () => {
const element = document.createElement('div');
element.id = 'shadow-app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#shadow-app',
useShadowRoot: true,
});
expect(app).toBeTruthy();
expect(element.shadowRoot).toBeTruthy();
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
expect(element.shadowRoot?.querySelector('.test-component')).toBeTruthy();
});
it('should inject styles into shadow root', () => {
const element = document.createElement('div');
element.id = 'shadow-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#shadow-app',
useShadowRoot: true,
});
const styleElement = element.shadowRoot?.querySelector('style[data-tailwind]');
expect(styleElement).toBeTruthy();
expect(styleElement?.textContent).toBe('.test { color: red; }');
});
it('should inject global styles to document', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
});
const globalStyle = document.querySelector('style[data-tailwind-global]');
expect(globalStyle).toBeTruthy();
expect(globalStyle?.textContent).toBe('.test { color: red; }');
});
it('should warn when app is already mounted', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app1 = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
const app2 = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(app1).toBeTruthy();
expect(app2).toBe(app1);
expect(consoleWarnSpy).toHaveBeenCalledWith('[VueMountApp] App test-app is already mounted');
});
it('should handle modal singleton behavior', () => {
const element1 = document.createElement('div');
element1.id = 'modals';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.id = 'unraid-modals';
document.body.appendChild(element2);
const app1 = mountVueApp({
component: TestComponent,
selector: '#modals',
appId: 'modals',
});
const app2 = mountVueApp({
component: TestComponent,
selector: '#unraid-modals',
appId: 'unraid-modals',
});
expect(app1).toBeTruthy();
expect(app2).toBe(app1);
expect(consoleDebugSpy).toHaveBeenCalledWith(
'[VueMountApp] Modals component already mounted as modals, skipping unraid-modals'
);
});
it('should clean up existing Vue attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.setAttribute('data-vue-mounted', 'true');
element.setAttribute('data-v-app', '');
element.setAttribute('data-server-rendered', 'true');
element.setAttribute('data-v-123', '');
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Element #app has Vue attributes but no content, cleaning up'
);
});
it('should handle elements with problematic child nodes', () => {
const element = document.createElement('div');
element.id = 'app';
element.appendChild(document.createTextNode(' '));
element.appendChild(document.createComment('test comment'));
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Cleaning up problematic nodes in #app before mounting'
);
});
it('should return null when no elements found', () => {
const app = mountVueApp({
component: TestComponent,
selector: '#non-existent',
});
expect(app).toBeNull();
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] No elements found for selector: #non-existent'
);
});
it('should handle mount errors gracefully', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const ErrorComponent = defineComponent({
setup() {
throw new Error('Component error');
},
});
expect(() => {
mountVueApp({
component: ErrorComponent,
selector: '#app',
});
}).toThrow('Component error');
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('should add unapi class to mounted elements', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(element.classList.contains('unapi')).toBe(true);
expect(element.getAttribute('data-vue-mounted')).toBe('true');
});
it('should skip disconnected elements during multi-mount', () => {
const element1 = document.createElement('div');
element1.className = 'multi';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi';
// This element is NOT added to the document
// Create a third element and manually add it to element1 to simulate DOM issues
const orphanedChild = document.createElement('span');
element1.appendChild(orphanedChild);
// Now remove element1 from DOM temporarily to trigger the warning
element1.remove();
// Add element1 back
document.body.appendChild(element1);
// Create elements matching the selector
document.body.innerHTML = '';
const validElement = document.createElement('div');
validElement.className = 'multi';
document.body.appendChild(validElement);
const disconnectedElement = document.createElement('div');
disconnectedElement.className = 'multi';
const container = document.createElement('div');
container.appendChild(disconnectedElement);
// Now disconnectedElement has a parent but that parent is not in the document
const app = mountVueApp({
component: TestComponent,
selector: '.multi',
});
expect(app).toBeTruthy();
// The app should mount only to the connected element
expect(validElement.querySelector('.test-component')).toBeTruthy();
});
});
describe('unmountVueApp', () => {
it('should unmount a mounted app', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(app).toBeTruthy();
expect(getMountedApp('test-app')).toBe(app);
const result = unmountVueApp('test-app');
expect(result).toBe(true);
expect(getMountedApp('test-app')).toBeUndefined();
});
it('should clean up data attributes on unmount', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(element.getAttribute('data-vue-mounted')).toBe('true');
expect(element.classList.contains('unapi')).toBe(true);
unmountVueApp('test-app');
expect(element.getAttribute('data-vue-mounted')).toBeNull();
});
it('should unmount cloned apps', () => {
const element1 = document.createElement('div');
element1.className = 'multi';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi';
document.body.appendChild(element2);
mountVueApp({
component: TestComponent,
selector: '.multi',
appId: 'multi-app',
});
const result = unmountVueApp('multi-app');
expect(result).toBe(true);
});
it('should remove shadow root containers', () => {
const element = document.createElement('div');
element.id = 'shadow-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#shadow-app',
appId: 'shadow-app',
useShadowRoot: true,
});
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
unmountVueApp('shadow-app');
expect(element.shadowRoot?.querySelector('#app')).toBeFalsy();
});
it('should warn when unmounting non-existent app', () => {
const result = unmountVueApp('non-existent');
expect(result).toBe(false);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] No app found with id: non-existent'
);
});
it('should handle unmount errors gracefully', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
// Force an error by corrupting the app
if (app) {
(app as { unmount: () => void }).unmount = () => {
throw new Error('Unmount error');
};
}
const result = unmountVueApp('test-app');
expect(result).toBe(true);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Error unmounting app test-app:',
expect.any(Error)
);
});
});
describe('getMountedApp', () => {
it('should return mounted app by id', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(getMountedApp('test-app')).toBe(app);
});
it('should return undefined for non-existent app', () => {
expect(getMountedApp('non-existent')).toBeUndefined();
});
});
describe('autoMountComponent', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should auto-mount when DOM is ready', async () => {
const element = document.createElement('div');
element.id = 'auto-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#auto-app');
await vi.runAllTimersAsync();
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should wait for DOMContentLoaded if document is loading', async () => {
Object.defineProperty(document, 'readyState', {
value: 'loading',
writable: true,
});
const element = document.createElement('div');
element.id = 'auto-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#auto-app');
expect(element.querySelector('.test-component')).toBeFalsy();
Object.defineProperty(document, 'readyState', {
value: 'complete',
writable: true,
});
document.dispatchEvent(new Event('DOMContentLoaded'));
await vi.runAllTimersAsync();
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should skip auto-mount for already mounted modals', async () => {
const element1 = document.createElement('div');
element1.id = 'modals';
document.body.appendChild(element1);
mountVueApp({
component: TestComponent,
selector: '#modals',
appId: 'modals',
});
autoMountComponent(TestComponent, '#modals');
await vi.runAllTimersAsync();
expect(consoleDebugSpy).toHaveBeenCalledWith(
'[VueMountApp] Modals component already mounted, skipping #modals'
);
});
it('should add delay for problematic selectors', async () => {
const element = document.createElement('div');
element.id = 'unraid-connect-settings';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#unraid-connect-settings');
await vi.advanceTimersByTimeAsync(100);
expect(element.querySelector('.test-component')).toBeFalsy();
await vi.advanceTimersByTimeAsync(200);
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should validate element visibility before mounting', async () => {
const element = document.createElement('div');
element.id = 'hidden-app';
element.style.display = 'none';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#hidden-app');
await vi.runAllTimersAsync();
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('No valid DOM elements found')
);
});
it('should handle nextSibling errors with retry', async () => {
const element = document.createElement('div');
element.id = 'error-app';
element.setAttribute('data-vue-mounted', 'true');
element.setAttribute('data-v-app', '');
document.body.appendChild(element);
// Simulate the element having Vue instance references which cause nextSibling errors
const mockVueInstance = { appContext: { app: {} as VueApp } };
(element as HTMLElementWithVue).__vueParentComponent = mockVueInstance;
// Add an invalid child that will trigger cleanup
const textNode = document.createTextNode(' ');
element.appendChild(textNode);
autoMountComponent(TestComponent, '#error-app');
await vi.runAllTimersAsync();
// Should detect and clean up existing Vue state
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('[VueMountApp] Element #error-app has Vue attributes but no content, cleaning up')
);
// Should successfully mount after cleanup
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should skip mounting if no elements found', async () => {
autoMountComponent(TestComponent, '#non-existent');
await vi.runAllTimersAsync();
expect(consoleWarnSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should pass options to mountVueApp', async () => {
const element = document.createElement('div');
element.id = 'options-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#options-app', {
props: { message: 'Auto Mount Message' },
useShadowRoot: true,
});
await vi.runAllTimersAsync();
expect(element.shadowRoot).toBeTruthy();
expect(element.shadowRoot?.textContent).toContain('Auto Mount Message');
});
});
describe('i18n setup', () => {
it('should setup i18n with default locale', () => {
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
expect(mockI18n.install).toHaveBeenCalled();
});
it('should parse window locale data', () => {
const localeData = {
fr_FR: { test: 'Message de test' },
};
(window as unknown as Record<string, unknown>).LOCALE_DATA = encodeURIComponent(JSON.stringify(localeData));
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
delete (window as unknown as Record<string, unknown>).LOCALE_DATA;
});
it('should handle locale data parsing errors', () => {
(window as unknown as Record<string, unknown>).LOCALE_DATA = 'invalid json';
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[VueMountApp] error parsing messages',
expect.any(Error)
);
delete (window as unknown as Record<string, unknown>).LOCALE_DATA;
});
});
describe('error recovery', () => {
it('should attempt recovery from nextSibling error', async () => {
vi.useFakeTimers();
const element = document.createElement('div');
element.id = 'recovery-app';
document.body.appendChild(element);
// Create a mock Vue app that throws on first mount attempt
let mountAttempt = 0;
const mockApp = {
use: vi.fn().mockReturnThis(),
provide: vi.fn().mockReturnThis(),
mount: vi.fn().mockImplementation(() => {
mountAttempt++;
if (mountAttempt === 1) {
const error = new TypeError('Cannot read property nextSibling of null');
throw error;
}
return mockApp;
}),
unmount: vi.fn(),
version: '3.0.0',
config: { globalProperties: {} },
};
// Mock createApp using module mock
const vueModule = await import('vue');
vi.spyOn(vueModule, 'createApp').mockReturnValue(mockApp as never);
mountVueApp({
component: TestComponent,
selector: '#recovery-app',
appId: 'recovery-app',
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Attempting recovery from nextSibling error for #recovery-app'
);
await vi.advanceTimersByTimeAsync(10);
expect(consoleInfoSpy).toHaveBeenCalledWith(
'[VueMountApp] Successfully recovered from nextSibling error for #recovery-app'
);
vi.useRealTimers();
});
it('should not attempt recovery with skipRecovery flag', async () => {
const element = document.createElement('div');
element.id = 'no-recovery-app';
document.body.appendChild(element);
const mockApp = {
use: vi.fn().mockReturnThis(),
provide: vi.fn().mockReturnThis(),
mount: vi.fn().mockImplementation(() => {
throw new TypeError('Cannot read property nextSibling of null');
}),
unmount: vi.fn(),
version: '3.0.0',
config: { globalProperties: {} },
};
const vueModule = await import('vue');
vi.spyOn(vueModule, 'createApp').mockReturnValue(mockApp as never);
expect(() => {
mountVueApp({
component: TestComponent,
selector: '#no-recovery-app',
skipRecovery: true,
});
}).toThrow('Cannot read property nextSibling of null');
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Attempting recovery')
);
});
});
describe('global exposure', () => {
it('should expose mountedApps globally', () => {
expect(window.mountedApps).toBeDefined();
expect(window.mountedApps).toBeInstanceOf(Map);
});
it('should expose globalPinia globally', () => {
expect(window.globalPinia).toBeDefined();
expect(window.globalPinia).toBe(mockGlobalPinia);
});
});
});

View File

@@ -156,7 +156,7 @@ const updateOsStatus = computed(() => {
>
</a>
<div class="flex flex-wrap justify-start gap-2">
<div class="flex flex-wrap justify-start gap-2 mt-2">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button

View File

@@ -23,6 +23,15 @@ const mountedAppContainers = new Map<string, HTMLElement[]>(); // shadow-root co
// Shared style injection tracking
const styleInjected = new WeakSet<Document | ShadowRoot>();
// Extend HTMLElement to include Vue's internal properties
interface HTMLElementWithVue extends HTMLElement {
__vueParentComponent?: {
appContext?: {
app?: VueApp;
};
};
}
// Expose globally for debugging
declare global {
interface Window {
@@ -94,6 +103,7 @@ export interface MountOptions {
appId?: string;
useShadowRoot?: boolean;
props?: Record<string, unknown>;
skipRecovery?: boolean; // Internal flag to prevent recursive recovery attempts
}
// Helper function to parse props from HTML attributes
@@ -133,7 +143,7 @@ function parsePropsFromElement(element: Element): Record<string, unknown> {
}
export function mountVueApp(options: MountOptions): VueApp | null {
const { component, selector, appId = selector, useShadowRoot = false, props = {} } = options;
const { component, selector, appId = selector, useShadowRoot = false, props = {}, skipRecovery = false } = options;
// Check if app is already mounted
if (mountedApps.has(appId)) {
@@ -141,6 +151,85 @@ export function mountVueApp(options: MountOptions): VueApp | null {
return mountedApps.get(appId)!;
}
// Special handling for modals - enforce singleton behavior
if (selector.includes('unraid-modals') || selector === '#modals') {
const existingModalApps = ['modals', 'modals-direct', 'unraid-modals'];
for (const modalId of existingModalApps) {
if (mountedApps.has(modalId)) {
console.debug(`[VueMountApp] Modals component already mounted as ${modalId}, skipping ${appId}`);
return mountedApps.get(modalId)!;
}
}
}
// Check if any elements matching the selector already have Vue apps mounted
const potentialTargets = document.querySelectorAll(selector);
for (const target of potentialTargets) {
const element = target as HTMLElementWithVue;
const hasVueAttributes = element.hasAttribute('data-vue-mounted') ||
element.hasAttribute('data-v-app') ||
element.hasAttribute('data-server-rendered');
if (hasVueAttributes || element.__vueParentComponent) {
// Check if the existing Vue component is actually working (has content)
const hasContent = element.innerHTML.trim().length > 0 ||
element.children.length > 0;
if (hasContent) {
console.info(`[VueMountApp] Element ${selector} already has working Vue component, skipping remount`);
// Return the existing app if we can find it
const existingApp = mountedApps.get(appId);
if (existingApp) {
return existingApp;
}
// If we can't find the app reference but component is working, return null (success)
return null;
}
console.warn(`[VueMountApp] Element ${selector} has Vue attributes but no content, cleaning up`);
try {
// DO NOT attempt to unmount existing Vue instances - this causes the nextSibling error
// Instead, just clear the DOM state and let Vue handle the cleanup naturally
// Remove all Vue-related attributes
element.removeAttribute('data-vue-mounted');
element.removeAttribute('data-v-app');
element.removeAttribute('data-server-rendered');
// Remove any Vue-injected attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
element.removeAttribute(attr.name);
}
});
// Clear the element content to ensure fresh state
element.innerHTML = '';
// Remove the __vueParentComponent reference without calling unmount
delete element.__vueParentComponent;
console.info(`[VueMountApp] Cleared Vue state from ${selector} without unmounting (prevents nextSibling errors)`);
} catch (error) {
console.warn(`[VueMountApp] Error cleaning up existing Vue instance:`, error);
// Force clear everything if normal cleanup fails
element.innerHTML = '';
element.removeAttribute('data-vue-mounted');
element.removeAttribute('data-v-app');
element.removeAttribute('data-server-rendered');
// Remove all data-v-* attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
element.removeAttribute(attr.name);
}
});
}
}
}
// Find all mount targets
const targets = document.querySelectorAll(selector);
if (targets.length === 0) {
@@ -174,8 +263,64 @@ export function mountVueApp(options: MountOptions): VueApp | null {
targets.forEach((target, index) => {
const mountTarget = target as HTMLElement;
// Add unapi class for minimal styling
// Comprehensive DOM validation
if (!mountTarget.isConnected || !mountTarget.parentNode || !document.contains(mountTarget)) {
console.warn(`[VueMountApp] Mount target not properly connected to DOM for ${appId}, skipping`);
return;
}
// Special handling for PHP-generated pages that might have whitespace/comment nodes
if (mountTarget.childNodes.length > 0) {
let hasProblematicNodes = false;
const nodesToRemove: Node[] = [];
Array.from(mountTarget.childNodes).forEach(node => {
// Check for orphaned nodes
if (node.parentNode !== mountTarget) {
hasProblematicNodes = true;
return;
}
// Check for empty text nodes or comments that could cause fragment issues
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === '') {
nodesToRemove.push(node);
hasProblematicNodes = true;
} else if (node.nodeType === Node.COMMENT_NODE) {
nodesToRemove.push(node);
hasProblematicNodes = true;
}
});
if (hasProblematicNodes) {
console.warn(`[VueMountApp] Cleaning up problematic nodes in ${selector} before mounting`);
// Remove problematic nodes
nodesToRemove.forEach(node => {
try {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
} catch (_e) {
// If removal fails, clear the entire content
mountTarget.innerHTML = '';
}
});
// If we still have orphaned nodes after cleanup, clear everything
const remainingInvalidChildren = Array.from(mountTarget.childNodes).filter(node => {
return node.parentNode !== mountTarget;
});
if (remainingInvalidChildren.length > 0) {
console.warn(`[VueMountApp] Clearing all content due to remaining orphaned nodes in ${selector}`);
mountTarget.innerHTML = '';
}
}
}
// Add unapi class for minimal styling and mark as mounted
mountTarget.classList.add('unapi');
mountTarget.setAttribute('data-vue-mounted', 'true');
if (useShadowRoot) {
// Create shadow root if needed
@@ -195,15 +340,26 @@ export function mountVueApp(options: MountOptions): VueApp | null {
// For the first target, use the main app, otherwise create clones
if (index === 0) {
app.mount(container);
try {
app.mount(container);
} catch (error) {
console.error(`[VueMountApp] Error mounting main app to shadow root ${selector}:`, error);
throw error;
}
} else {
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
const clonedApp = createApp(component, targetProps);
clonedApp.use(i18n);
clonedApp.use(globalPinia);
clonedApp.provide(DefaultApolloClient, apolloClient);
clonedApp.mount(container);
clones.push(clonedApp);
try {
clonedApp.mount(container);
clones.push(clonedApp);
} catch (error) {
console.error(`[VueMountApp] Error mounting cloned app to shadow root ${selector}:`, error);
// Don't call unmount since mount failed - just let the app be garbage collected
}
}
} else {
// Direct mount without shadow root
@@ -213,7 +369,45 @@ export function mountVueApp(options: MountOptions): VueApp | null {
// but they'll share the same Pinia store
if (index === 0) {
// First target, use the main app
app.mount(mountTarget);
try {
// Final validation before mounting
if (!mountTarget.isConnected || !document.contains(mountTarget)) {
throw new Error(`Mount target disconnected before mounting: ${selector}`);
}
app.mount(mountTarget);
} catch (error) {
console.error(`[VueMountApp] Error mounting main app to ${selector}:`, error);
// Special handling for nextSibling error - attempt recovery (only if not already retrying)
if (!skipRecovery && error instanceof TypeError && error.message.includes('nextSibling')) {
console.warn(`[VueMountApp] Attempting recovery from nextSibling error for ${selector}`);
// Remove the problematic data attribute that might be causing issues
mountTarget.removeAttribute('data-vue-mounted');
// Try mounting after a brief delay to let DOM settle
setTimeout(() => {
try {
// Ensure element is still valid
if (mountTarget.isConnected && document.contains(mountTarget)) {
app.mount(mountTarget);
mountTarget.setAttribute('data-vue-mounted', 'true');
console.info(`[VueMountApp] Successfully recovered from nextSibling error for ${selector}`);
} else {
console.error(`[VueMountApp] Recovery failed - element no longer in DOM: ${selector}`);
}
} catch (retryError) {
console.error(`[VueMountApp] Recovery attempt failed for ${selector}:`, retryError);
}
}, 10);
// Return without throwing to allow other elements to mount
return;
}
throw error;
}
} else {
// Additional targets, create cloned apps with their own props
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
@@ -221,8 +415,14 @@ export function mountVueApp(options: MountOptions): VueApp | null {
clonedApp.use(i18n);
clonedApp.use(globalPinia); // Shared Pinia instance
clonedApp.provide(DefaultApolloClient, apolloClient);
clonedApp.mount(mountTarget);
clones.push(clonedApp);
try {
clonedApp.mount(mountTarget);
clones.push(clonedApp);
} catch (error) {
console.error(`[VueMountApp] Error mounting cloned app to ${selector}:`, error);
// Don't call unmount since mount failed - just let the app be garbage collected
}
}
}
});
@@ -242,17 +442,43 @@ export function unmountVueApp(appId: string): boolean {
return false;
}
// Unmount clones first
// Unmount clones first with error handling
const clones = mountedAppClones.get(appId) ?? [];
for (const c of clones) c.unmount();
for (const c of clones) {
try {
c.unmount();
} catch (error) {
console.warn(`[VueMountApp] Error unmounting clone for ${appId}:`, error);
}
}
mountedAppClones.delete(appId);
// Remove shadow containers
// Remove shadow containers with error handling
const containers = mountedAppContainers.get(appId) ?? [];
for (const el of containers) el.remove();
for (const el of containers) {
try {
el.remove();
} catch (error) {
console.warn(`[VueMountApp] Error removing container for ${appId}:`, error);
}
}
mountedAppContainers.delete(appId);
app.unmount();
// Unmount main app with error handling
try {
app.unmount();
// Clean up data attributes from mounted elements
const elements = document.querySelectorAll(`[data-vue-mounted="true"]`);
elements.forEach(el => {
if (el.classList.contains('unapi')) {
el.removeAttribute('data-vue-mounted');
}
});
} catch (error) {
console.warn(`[VueMountApp] Error unmounting app ${appId}:`, error);
}
mountedApps.delete(appId);
return true;
}
@@ -264,16 +490,122 @@ export function getMountedApp(appId: string): VueApp | undefined {
// Auto-mount function for script tags
export function autoMountComponent(component: Component, selector: string, options?: Partial<MountOptions>) {
const tryMount = () => {
// Check if elements exist before attempting to mount
if (document.querySelector(selector)) {
try {
mountVueApp({ component, selector, ...options });
} catch (error) {
console.error(`[VueMountApp] Failed to mount component for selector ${selector}:`, error);
// Special handling for modals - should only mount once, ignore subsequent attempts
if (selector.includes('unraid-modals') || selector === '#modals') {
const modalAppId = options?.appId || 'modals';
if (mountedApps.has(modalAppId) || mountedApps.has('modals-direct')) {
console.debug(`[VueMountApp] Modals component already mounted, skipping ${selector}`);
return;
}
}
// Check if elements exist before attempting to mount
const elements = document.querySelectorAll(selector);
if (elements.length > 0) {
// For specific problematic selectors, add extra delay to let page scripts settle
const isProblematicSelector = selector.includes('unraid-connect-settings') ||
selector.includes('unraid-modals') ||
selector.includes('unraid-theme-switcher');
if (isProblematicSelector) {
// Wait longer for PHP-generated pages with dynamic content
setTimeout(() => {
performMount();
}, 200);
return;
}
performMount();
}
// Silently skip if no elements found - this is expected for most components
};
const performMount = () => {
const elements = document.querySelectorAll(selector);
if (elements.length === 0) return;
// Validate all elements are properly connected to the DOM and not being manipulated
const validElements = Array.from(elements).filter(el => {
const element = el as HTMLElement;
// Basic connectivity check
if (!element.isConnected || !element.parentNode || !document.contains(element)) {
return false;
}
// Check if the element appears to be in a stable state
const rect = element.getBoundingClientRect();
const hasStableGeometry = rect.width >= 0 && rect.height >= 0;
// Check if element is being hidden/manipulated by other scripts
const computedStyle = window.getComputedStyle(element);
const isVisible = computedStyle.display !== 'none' &&
computedStyle.visibility !== 'hidden' &&
computedStyle.opacity !== '0';
if (!hasStableGeometry) {
console.debug(`[VueMountApp] Element ${selector} has unstable geometry, may be manipulated by scripts`);
}
return hasStableGeometry && isVisible;
});
if (validElements.length > 0) {
try {
mountVueApp({ component, selector, ...options });
} catch (error) {
console.error(`[VueMountApp] Failed to mount component for selector ${selector}:`, error);
// Additional debugging for this specific error
if (error instanceof TypeError && error.message.includes('nextSibling')) {
console.warn(`[VueMountApp] DOM state issue detected for ${selector}, attempting cleanup and retry`);
// Perform more aggressive cleanup for nextSibling errors
validElements.forEach(el => {
const element = el as HTMLElement;
// Remove all Vue-related attributes that might be causing issues
element.removeAttribute('data-vue-mounted');
element.removeAttribute('data-v-app');
Array.from(element.attributes).forEach(attr => {
if (attr.name.startsWith('data-v-')) {
element.removeAttribute(attr.name);
}
});
// Completely reset the element's content and state
element.innerHTML = '';
element.className = element.className.replace(/\bunapi\b/g, '').trim();
// Remove any Vue instance references
delete (element as unknown as HTMLElementWithVue).__vueParentComponent;
});
// Wait for DOM to stabilize and try again
setTimeout(() => {
try {
console.info(`[VueMountApp] Retrying mount for ${selector} after cleanup`);
mountVueApp({ component, selector, ...options, skipRecovery: true });
} catch (retryError) {
console.error(`[VueMountApp] Retry failed for ${selector}:`, retryError);
// If retry also fails, try one more time with even more delay
setTimeout(() => {
try {
console.info(`[VueMountApp] Final retry attempt for ${selector}`);
mountVueApp({ component, selector, ...options, skipRecovery: true });
} catch (finalError) {
console.error(`[VueMountApp] All retry attempts failed for ${selector}:`, finalError);
}
}, 100);
}
}, 50);
}
}
} else {
console.warn(`[VueMountApp] No valid DOM elements found for ${selector} (${elements.length} elements exist but not properly connected)`);
}
};
// Wait for DOM to be ready
if (document.readyState === 'loading') {