Skip to content

Commit d8b166e

Browse files
authored
feat: improve dom content loading by being more efficient about component mounting (#1716)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Faster, more scalable component auto-mounting via batch processing. - More robust prop parsing (handles JSON vs. strings and HTML entities). - Improved locale data initialization during setup. - Bug Fixes - Prevents duplicate mounts and improves handling of empty/irrelevant attributes. - Refactor - Consolidated mounting flow and removed legacy runtime debug globals. - Tests - Removed outdated tests tied to previous global exposures. - Chores - Updated type declarations; global client is now optional for improved flexibility. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 8b862ec commit d8b166e

File tree

5 files changed

+95
-101
lines changed

5 files changed

+95
-101
lines changed

web/__test__/components/Wrapper/mount-engine.test.ts

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,7 @@ describe('mount-engine', () => {
105105
vi.restoreAllMocks();
106106
document.body.innerHTML = '';
107107
// Clean up global references
108-
if (window.__unifiedApp) {
109-
delete window.__unifiedApp;
110-
}
111-
if (window.__mountedComponents) {
112-
delete window.__mountedComponents;
113-
}
108+
// Clean up any window references if needed
114109
});
115110

116111
describe('mountUnifiedApp', () => {
@@ -438,29 +433,6 @@ describe('mount-engine', () => {
438433
});
439434

440435
describe('global exposure', () => {
441-
it('should expose unified app globally', () => {
442-
const app = mountUnifiedApp();
443-
expect(window.__unifiedApp).toBe(app);
444-
});
445-
446-
it('should expose mounted components globally', () => {
447-
const element = document.createElement('div');
448-
element.id = 'global-app';
449-
document.body.appendChild(element);
450-
451-
mockComponentMappings.push({
452-
selector: '#global-app',
453-
appId: 'global-app',
454-
component: TestComponent,
455-
});
456-
457-
mountUnifiedApp();
458-
459-
expect(window.__mountedComponents).toBeDefined();
460-
expect(Array.isArray(window.__mountedComponents)).toBe(true);
461-
expect(window.__mountedComponents!.length).toBe(1);
462-
});
463-
464436
it('should expose globalPinia globally', () => {
465437
expect(window.globalPinia).toBeDefined();
466438
expect(window.globalPinia).toBe(mockGlobalPinia);

web/src/assets/main.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
/* Import theme and utilities only - no global preflight */
1010
@import "tailwindcss/theme.css" layer(theme);
1111
@import "tailwindcss/utilities.css" layer(utilities);
12-
@import "@nuxt/ui";
12+
/* @import "@nuxt/ui"; temporarily disabled */
1313
@import 'tw-animate-css';
1414
@import '../../../@tailwind-shared/index.css';
1515

web/src/components/Wrapper/auto-mount.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,6 @@ function initializeGlobalDependencies() {
2323
});
2424

2525
// Expose utility functions on window for debugging/external use
26-
// With unified app, these are no longer needed
27-
// Access the unified app via window.__unifiedApp instead
28-
2926
// Expose Apollo client on window for global access
3027
window.apolloClient = apolloClient;
3128

web/src/components/Wrapper/mount-engine.ts

Lines changed: 91 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ import { client } from '~/helpers/create-apollo-client';
1010
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
1111
import en_US from '~/locales/en_US.json';
1212

13-
import type { App as VueApp } from 'vue';
14-
1513
// Import Pinia for use in Vue apps
1614
import { globalPinia } from '~/store/globalPinia';
1715

@@ -22,7 +20,7 @@ const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || c
2220
declare global {
2321
interface Window {
2422
globalPinia: typeof globalPinia;
25-
__unifiedApp?: VueApp;
23+
LOCALE_DATA?: string;
2624
}
2725
}
2826

@@ -38,7 +36,7 @@ function setupI18n() {
3836

3937
// Check for window locale data
4038
if (typeof window !== 'undefined') {
41-
const windowLocaleData = (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA || null;
39+
const windowLocaleData = window.LOCALE_DATA || null;
4240
if (windowLocaleData) {
4341
try {
4442
parsedMessages = JSON.parse(decodeURIComponent(windowLocaleData));
@@ -64,19 +62,26 @@ function setupI18n() {
6462

6563
// Helper function to parse props from HTML attributes
6664
function parsePropsFromElement(element: Element): Record<string, unknown> {
65+
// Early exit if no attributes
66+
if (!element.hasAttributes()) return {};
67+
6768
const props: Record<string, unknown> = {};
69+
// Pre-compile attribute skip list into a Set for O(1) lookup
70+
const skipAttrs = new Set(['class', 'id', 'style', 'data-vue-mounted']);
6871

6972
for (const attr of element.attributes) {
7073
const name = attr.name;
71-
const value = attr.value;
7274

7375
// Skip Vue internal attributes and common HTML attributes
74-
if (name.startsWith('data-v-') || name === 'class' || name === 'id' || name === 'style') {
76+
if (skipAttrs.has(name) || name.startsWith('data-v-')) {
7577
continue;
7678
}
7779

80+
const value = attr.value;
81+
const first = value.trimStart()[0];
82+
7883
// Try to parse JSON values (handles HTML-encoded JSON)
79-
if (value.startsWith('{') || value.startsWith('[')) {
84+
if (first === '{' || first === '[') {
8085
try {
8186
// Decode HTML entities first
8287
const decoded = value
@@ -126,75 +131,95 @@ export function mountUnifiedApp() {
126131
// Now render components to their locations using the shared context
127132
const mountedComponents: Array<{ element: HTMLElement; unmount: () => void }> = [];
128133

129-
// Components are already in priority order in component-registry
134+
// Batch all selector queries first to identify which components are needed
135+
const componentsToMount: Array<{ mapping: (typeof componentMappings)[0]; element: HTMLElement }> = [];
136+
137+
// Build a map of all selectors to their mappings for quick lookup
138+
const selectorToMapping = new Map<string, (typeof componentMappings)[0]>();
130139
componentMappings.forEach((mapping) => {
131-
const { selector, appId } = mapping;
132-
const selectors = Array.isArray(selector) ? selector : [selector];
133-
134-
// Find first matching element
135-
for (const sel of selectors) {
136-
const element = document.querySelector(sel) as HTMLElement;
137-
if (element && !element.hasAttribute('data-vue-mounted')) {
138-
// Get the async component from mapping
139-
const component = mapping.component;
140-
141-
// Skip if no component is defined
142-
if (!component) {
143-
console.error(`[UnifiedMount] No component defined for ${appId}`);
144-
continue;
145-
}
140+
const selectors = Array.isArray(mapping.selector) ? mapping.selector : [mapping.selector];
141+
selectors.forEach((sel) => selectorToMapping.set(sel, mapping));
142+
});
146143

147-
// Parse props from element
148-
const props = parsePropsFromElement(element);
149-
150-
// Wrap component in UApp for Nuxt UI support
151-
const wrappedComponent = {
152-
name: `${appId}-wrapper`,
153-
setup() {
154-
return () =>
155-
h(
156-
UApp,
157-
{},
158-
{
159-
default: () => h(component, props),
160-
}
161-
);
162-
},
163-
};
164-
165-
// Create vnode with shared app context
166-
const vnode = createVNode(wrappedComponent);
167-
vnode.appContext = app._context; // Share the app context
168-
169-
// Clear the element and render the component into it
170-
element.innerHTML = '';
171-
render(vnode, element);
172-
173-
// Mark as mounted
174-
element.setAttribute('data-vue-mounted', 'true');
175-
element.classList.add('unapi');
176-
177-
// Store for cleanup
178-
mountedComponents.push({
179-
element,
180-
unmount: () => render(null, element),
181-
});
182-
183-
break;
144+
// Query all selectors at once
145+
const allSelectors = Array.from(selectorToMapping.keys()).join(',');
146+
147+
// Early exit if no selectors to query
148+
if (!allSelectors) {
149+
console.debug('[UnifiedMount] Mounted 0 components');
150+
return app;
151+
}
152+
153+
const foundElements = document.querySelectorAll(allSelectors);
154+
const processedMappings = new Set<(typeof componentMappings)[0]>();
155+
156+
foundElements.forEach((element) => {
157+
if (!element.hasAttribute('data-vue-mounted')) {
158+
// Find which mapping this element belongs to
159+
for (const [selector, mapping] of selectorToMapping) {
160+
if (element.matches(selector) && !processedMappings.has(mapping)) {
161+
componentsToMount.push({ mapping, element: element as HTMLElement });
162+
processedMappings.add(mapping);
163+
break;
164+
}
184165
}
185166
}
186167
});
187168

188-
// Store reference for debugging
189-
if (typeof window !== 'undefined') {
190-
window.__unifiedApp = app;
191-
window.__mountedComponents = mountedComponents;
192-
}
169+
// Now mount only the components that exist
170+
componentsToMount.forEach(({ mapping, element }) => {
171+
const { appId } = mapping;
172+
const component = mapping.component;
173+
174+
// Skip if no component is defined
175+
if (!component) {
176+
console.error(`[UnifiedMount] No component defined for ${appId}`);
177+
return;
178+
}
179+
180+
// Parse props from element
181+
const props = parsePropsFromElement(element);
182+
183+
// Wrap component in UApp for Nuxt UI support
184+
const wrappedComponent = {
185+
name: `${appId}-wrapper`,
186+
setup() {
187+
return () =>
188+
h(
189+
UApp,
190+
{},
191+
{
192+
default: () => h(component, props),
193+
}
194+
);
195+
},
196+
};
197+
198+
// Create vnode with shared app context
199+
const vnode = createVNode(wrappedComponent);
200+
vnode.appContext = app._context; // Share the app context
201+
202+
// Clear the element and render the component into it
203+
element.replaceChildren();
204+
render(vnode, element);
205+
206+
// Mark as mounted
207+
element.setAttribute('data-vue-mounted', 'true');
208+
element.classList.add('unapi');
209+
210+
// Store for cleanup
211+
mountedComponents.push({
212+
element,
213+
unmount: () => render(null, element),
214+
});
215+
});
216+
217+
console.debug(`[UnifiedMount] Mounted ${mountedComponents.length} components`);
193218

194219
return app;
195220
}
196221

197222
// Replace the old autoMountAllComponents with the new unified approach
198223
export function autoMountAllComponents() {
199-
mountUnifiedApp();
224+
return mountUnifiedApp();
200225
}

web/types/window.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import type { ApolloClient } from '@apollo/client/core';
12
import type { autoMountComponent, getMountedApp, mountVueApp } from '~/components/Wrapper/mount-engine';
2-
import type { client as apolloClient } from '~/helpers/create-apollo-client';
33
import type { parse } from 'graphql';
44
import type { Component } from 'vue';
55

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

0 commit comments

Comments
 (0)