Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 1 addition & 29 deletions web/__test__/components/Wrapper/mount-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion web/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
3 changes: 0 additions & 3 deletions web/src/components/Wrapper/auto-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
157 changes: 91 additions & 66 deletions web/src/components/Wrapper/mount-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -22,7 +20,7 @@ const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || c
declare global {
interface Window {
globalPinia: typeof globalPinia;
__unifiedApp?: VueApp;
LOCALE_DATA?: string;
}
}

Expand All @@ -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));
Expand All @@ -64,19 +62,26 @@ function setupI18n() {

// Helper function to parse props from HTML attributes
function parsePropsFromElement(element: Element): Record<string, unknown> {
// Early exit if no attributes
if (!element.hasAttributes()) return {};

const props: Record<string, unknown> = {};
// 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
Expand Down Expand Up @@ -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<string, (typeof componentMappings)[0]>();
componentMappings.forEach((mapping) => {
const { selector, appId } = mapping;
const selectors = Array.isArray(selector) ? selector : [selector];

// 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;

// Skip if no component is defined
if (!component) {
console.error(`[UnifiedMount] No component defined for ${appId}`);
continue;
}
const selectors = Array.isArray(mapping.selector) ? mapping.selector : [mapping.selector];
selectors.forEach((sel) => selectorToMapping.set(sel, mapping));
});

// 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;
// Query all selectors at once
const allSelectors = Array.from(selectorToMapping.keys()).join(',');

// 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;
}
}
}
});

// 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();
}
4 changes: 2 additions & 2 deletions web/types/window.d.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,7 +11,7 @@ import type { Component } from 'vue';
declare global {
interface Window {
// Apollo GraphQL client and utilities
apolloClient: typeof apolloClient;
apolloClient?: ApolloClient<unknown>;
gql: typeof parse;
graphqlParse: typeof parse;

Expand Down
Loading