Compare commits

...

2 Commits

Author SHA1 Message Date
Eli Bosley
d8b166e4b6 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 -->
2025-09-18 12:50:24 -04:00
github-actions[bot]
8b862ecef5 chore(main): release 4.23.1 (#1715)
🤖 I have created a release *beep* *boop*
---


## [4.23.1](https://github.com/unraid/api/compare/v4.23.0...v4.23.1)
(2025-09-17)


### Bug Fixes

* cleanup ini parser logic with better fallbacks
([#1713](https://github.com/unraid/api/issues/1713))
([1691362](16913627de))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-17 14:46:58 -04:00
12 changed files with 106 additions and 105 deletions

View File

@@ -1 +1 @@
{".":"4.23.0"}
{".":"4.23.1"}

View File

@@ -1,5 +1,12 @@
# Changelog
## [4.23.1](https://github.com/unraid/api/compare/v4.23.0...v4.23.1) (2025-09-17)
### Bug Fixes
* cleanup ini parser logic with better fallbacks ([#1713](https://github.com/unraid/api/issues/1713)) ([1691362](https://github.com/unraid/api/commit/16913627de9497a5d2f71edb710cec6e2eb9f890))
## [4.23.0](https://github.com/unraid/api/compare/v4.22.2...v4.23.0) (2025-09-16)

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.23.0",
"version": "4.23.1",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {

View File

@@ -1,7 +1,7 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.23.0",
"version": "4.23.1",
"scripts": {
"build": "pnpm -r build",
"build:watch": "pnpm -r --parallel --filter '!@unraid/ui' build:watch",

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/connect-plugin",
"version": "4.23.0",
"version": "4.23.1",
"private": true,
"dependencies": {
"commander": "14.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/ui",
"version": "4.23.0",
"version": "4.23.1",
"private": true,
"license": "GPL-2.0-or-later",
"type": "module",

View File

@@ -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);

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.23.0",
"version": "4.23.1",
"private": true,
"type": "module",
"license": "GPL-2.0-or-later",

View File

@@ -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';

View File

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

View File

@@ -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<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
@@ -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];
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();
}

View File

@@ -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<unknown>;
gql: typeof parse;
graphqlParse: typeof parse;