mirror of
https://github.com/unraid/api.git
synced 2025-12-30 21:19:49 -06:00
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:
864
web/__test__/components/Wrapper/vue-mount-app.test.ts
Normal file
864
web/__test__/components/Wrapper/vue-mount-app.test.ts
Normal 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', '{"text": "Encoded"}');
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element.getAttribute('message')).toBe('{"text": "Encoded"}');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user