diff --git a/web/__test__/components/Wrapper/mount-engine.test.ts b/web/__test__/components/Wrapper/mount-engine.test.ts index 8868b0544..aeb9c2ff5 100644 --- a/web/__test__/components/Wrapper/mount-engine.test.ts +++ b/web/__test__/components/Wrapper/mount-engine.test.ts @@ -105,12 +105,7 @@ describe('mount-engine', () => { vi.restoreAllMocks(); document.body.innerHTML = ''; // Clean up global references - if (window.__unifiedApp) { - delete window.__unifiedApp; - } - if (window.__mountedComponents) { - delete window.__mountedComponents; - } + // Clean up any window references if needed }); describe('mountUnifiedApp', () => { @@ -438,29 +433,6 @@ describe('mount-engine', () => { }); describe('global exposure', () => { - it('should expose unified app globally', () => { - const app = mountUnifiedApp(); - expect(window.__unifiedApp).toBe(app); - }); - - it('should expose mounted components globally', () => { - const element = document.createElement('div'); - element.id = 'global-app'; - document.body.appendChild(element); - - mockComponentMappings.push({ - selector: '#global-app', - appId: 'global-app', - component: TestComponent, - }); - - mountUnifiedApp(); - - expect(window.__mountedComponents).toBeDefined(); - expect(Array.isArray(window.__mountedComponents)).toBe(true); - expect(window.__mountedComponents!.length).toBe(1); - }); - it('should expose globalPinia globally', () => { expect(window.globalPinia).toBeDefined(); expect(window.globalPinia).toBe(mockGlobalPinia); diff --git a/web/src/assets/main.css b/web/src/assets/main.css index 4d116fa46..a4221aec1 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -9,7 +9,7 @@ /* Import theme and utilities only - no global preflight */ @import "tailwindcss/theme.css" layer(theme); @import "tailwindcss/utilities.css" layer(utilities); -@import "@nuxt/ui"; +/* @import "@nuxt/ui"; temporarily disabled */ @import 'tw-animate-css'; @import '../../../@tailwind-shared/index.css'; diff --git a/web/src/components/Wrapper/auto-mount.ts b/web/src/components/Wrapper/auto-mount.ts index c8e1577a7..9425029d0 100644 --- a/web/src/components/Wrapper/auto-mount.ts +++ b/web/src/components/Wrapper/auto-mount.ts @@ -23,9 +23,6 @@ function initializeGlobalDependencies() { }); // Expose utility functions on window for debugging/external use - // With unified app, these are no longer needed - // Access the unified app via window.__unifiedApp instead - // Expose Apollo client on window for global access window.apolloClient = apolloClient; diff --git a/web/src/components/Wrapper/mount-engine.ts b/web/src/components/Wrapper/mount-engine.ts index e708bcaf5..e86150ec8 100644 --- a/web/src/components/Wrapper/mount-engine.ts +++ b/web/src/components/Wrapper/mount-engine.ts @@ -10,8 +10,6 @@ import { client } from '~/helpers/create-apollo-client'; import { createHtmlEntityDecoder } from '~/helpers/i18n-utils'; import en_US from '~/locales/en_US.json'; -import type { App as VueApp } from 'vue'; - // Import Pinia for use in Vue apps import { globalPinia } from '~/store/globalPinia'; @@ -22,7 +20,7 @@ const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || c declare global { interface Window { globalPinia: typeof globalPinia; - __unifiedApp?: VueApp; + LOCALE_DATA?: string; } } @@ -38,7 +36,7 @@ function setupI18n() { // Check for window locale data if (typeof window !== 'undefined') { - const windowLocaleData = (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA || null; + const windowLocaleData = window.LOCALE_DATA || null; if (windowLocaleData) { try { parsedMessages = JSON.parse(decodeURIComponent(windowLocaleData)); @@ -64,19 +62,26 @@ function setupI18n() { // Helper function to parse props from HTML attributes function parsePropsFromElement(element: Element): Record { + // Early exit if no attributes + if (!element.hasAttributes()) return {}; + const props: Record = {}; + // Pre-compile attribute skip list into a Set for O(1) lookup + const skipAttrs = new Set(['class', 'id', 'style', 'data-vue-mounted']); for (const attr of element.attributes) { const name = attr.name; - const value = attr.value; // Skip Vue internal attributes and common HTML attributes - if (name.startsWith('data-v-') || name === 'class' || name === 'id' || name === 'style') { + if (skipAttrs.has(name) || name.startsWith('data-v-')) { continue; } + const value = attr.value; + const first = value.trimStart()[0]; + // Try to parse JSON values (handles HTML-encoded JSON) - if (value.startsWith('{') || value.startsWith('[')) { + if (first === '{' || first === '[') { try { // Decode HTML entities first const decoded = value @@ -126,75 +131,95 @@ export function mountUnifiedApp() { // Now render components to their locations using the shared context const mountedComponents: Array<{ element: HTMLElement; unmount: () => void }> = []; - // Components are already in priority order in component-registry + // Batch all selector queries first to identify which components are needed + const componentsToMount: Array<{ mapping: (typeof componentMappings)[0]; element: HTMLElement }> = []; + + // Build a map of all selectors to their mappings for quick lookup + const selectorToMapping = new Map(); componentMappings.forEach((mapping) => { - const { selector, appId } = mapping; - const selectors = Array.isArray(selector) ? selector : [selector]; + const selectors = Array.isArray(mapping.selector) ? mapping.selector : [mapping.selector]; + selectors.forEach((sel) => selectorToMapping.set(sel, mapping)); + }); - // Find first matching element - for (const sel of selectors) { - const element = document.querySelector(sel) as HTMLElement; - if (element && !element.hasAttribute('data-vue-mounted')) { - // Get the async component from mapping - const component = mapping.component; + // Query all selectors at once + const allSelectors = Array.from(selectorToMapping.keys()).join(','); - // Skip if no component is defined - if (!component) { - console.error(`[UnifiedMount] No component defined for ${appId}`); - continue; + // Early exit if no selectors to query + if (!allSelectors) { + console.debug('[UnifiedMount] Mounted 0 components'); + return app; + } + + const foundElements = document.querySelectorAll(allSelectors); + const processedMappings = new Set<(typeof componentMappings)[0]>(); + + foundElements.forEach((element) => { + if (!element.hasAttribute('data-vue-mounted')) { + // Find which mapping this element belongs to + for (const [selector, mapping] of selectorToMapping) { + if (element.matches(selector) && !processedMappings.has(mapping)) { + componentsToMount.push({ mapping, element: element as HTMLElement }); + processedMappings.add(mapping); + break; } - - // Parse props from element - const props = parsePropsFromElement(element); - - // Wrap component in UApp for Nuxt UI support - const wrappedComponent = { - name: `${appId}-wrapper`, - setup() { - return () => - h( - UApp, - {}, - { - default: () => h(component, props), - } - ); - }, - }; - - // Create vnode with shared app context - const vnode = createVNode(wrappedComponent); - vnode.appContext = app._context; // Share the app context - - // Clear the element and render the component into it - element.innerHTML = ''; - render(vnode, element); - - // Mark as mounted - element.setAttribute('data-vue-mounted', 'true'); - element.classList.add('unapi'); - - // Store for cleanup - mountedComponents.push({ - element, - unmount: () => render(null, element), - }); - - break; } } }); - // Store reference for debugging - if (typeof window !== 'undefined') { - window.__unifiedApp = app; - window.__mountedComponents = mountedComponents; - } + // Now mount only the components that exist + componentsToMount.forEach(({ mapping, element }) => { + const { appId } = mapping; + const component = mapping.component; + + // Skip if no component is defined + if (!component) { + console.error(`[UnifiedMount] No component defined for ${appId}`); + return; + } + + // Parse props from element + const props = parsePropsFromElement(element); + + // Wrap component in UApp for Nuxt UI support + const wrappedComponent = { + name: `${appId}-wrapper`, + setup() { + return () => + h( + UApp, + {}, + { + default: () => h(component, props), + } + ); + }, + }; + + // Create vnode with shared app context + const vnode = createVNode(wrappedComponent); + vnode.appContext = app._context; // Share the app context + + // Clear the element and render the component into it + element.replaceChildren(); + render(vnode, element); + + // Mark as mounted + element.setAttribute('data-vue-mounted', 'true'); + element.classList.add('unapi'); + + // Store for cleanup + mountedComponents.push({ + element, + unmount: () => render(null, element), + }); + }); + + console.debug(`[UnifiedMount] Mounted ${mountedComponents.length} components`); return app; } // Replace the old autoMountAllComponents with the new unified approach export function autoMountAllComponents() { - mountUnifiedApp(); + return mountUnifiedApp(); } diff --git a/web/types/window.d.ts b/web/types/window.d.ts index 5f376e57f..cf364c59b 100644 --- a/web/types/window.d.ts +++ b/web/types/window.d.ts @@ -1,5 +1,5 @@ +import type { ApolloClient } from '@apollo/client/core'; import type { autoMountComponent, getMountedApp, mountVueApp } from '~/components/Wrapper/mount-engine'; -import type { client as apolloClient } from '~/helpers/create-apollo-client'; import type { parse } from 'graphql'; import type { Component } from 'vue'; @@ -11,7 +11,7 @@ import type { Component } from 'vue'; declare global { interface Window { // Apollo GraphQL client and utilities - apolloClient: typeof apolloClient; + apolloClient?: ApolloClient; gql: typeof parse; graphqlParse: typeof parse;