Skip to content

Commit 98d37bb

Browse files
authored
fix(web): avoid dropdown id collisions with ZFS Master (#1958)
## Summary - add a small compatibility shim that rewrites dropdown-related ids and ARIA references away from the legacy `dropdown-` segment - enable the shim during unified app mounting so dynamically added menu nodes are sanitized too - add a regression test covering the id and ARIA rewrites ## Root Cause Some legacy Unraid plugins target DOM ids containing `dropdown-`. Our Reka dropdown markup uses ids like `reka-dropdown-menu-*`, which lets those plugins accidentally hide the Connect menu immediately after it renders. ## Testing - pnpm test __test__/components/Wrapper/mount-engine.test.ts
1 parent 0e004a7 commit 98d37bb

File tree

3 files changed

+105
-0
lines changed

3 files changed

+105
-0
lines changed

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,28 @@ describe('mount-engine', () => {
248248
expect(lastUAppPortal).toBe('#unraid-api-modals-virtual');
249249
});
250250

251+
it('should sanitize conflicting dropdown ids that legacy plugins target', async () => {
252+
await mountUnifiedApp();
253+
254+
const trigger = document.createElement('button');
255+
trigger.setAttribute('id', 'reka-dropdown-menu-trigger-1');
256+
document.body.appendChild(trigger);
257+
258+
const content = document.createElement('div');
259+
content.setAttribute('id', 'reka-dropdown-menu-content-1');
260+
content.setAttribute('aria-labelledby', 'reka-dropdown-menu-trigger-1');
261+
document.body.appendChild(content);
262+
263+
trigger.setAttribute('aria-controls', 'reka-dropdown-menu-content-1');
264+
265+
await vi.waitFor(() => {
266+
expect(trigger.getAttribute('id')).toBe('reka-menu-trigger-1');
267+
expect(trigger.getAttribute('aria-controls')).toBe('reka-menu-content-1');
268+
expect(content.getAttribute('id')).toBe('reka-menu-content-1');
269+
expect(content.getAttribute('aria-labelledby')).toBe('reka-menu-trigger-1');
270+
});
271+
});
272+
251273
it('should decorate the parent container when requested', async () => {
252274
const container = document.createElement('div');
253275
container.id = 'container';

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createI18nInstance, ensureLocale, getWindowLocale } from '~/helpers/i18
1111

1212
// Import Pinia for use in Vue apps
1313
import { globalPinia } from '~/store/globalPinia';
14+
import { enableDropdownIdCompatibility } from '~/utils/dropdownIdCompatibility';
1415
import { ensureUnapiScope, ensureUnapiScopeForSelectors, observeUnapiScope } from '~/utils/unapiScope';
1516

1617
// Ensure Apollo client is singleton
@@ -128,6 +129,8 @@ function parsePropsFromElement(element: Element): Record<string, unknown> {
128129

129130
// Create and mount unified app with shared context
130131
export async function mountUnifiedApp() {
132+
enableDropdownIdCompatibility();
133+
131134
// Create a minimal app just for context sharing
132135
const app = createApp({
133136
name: 'UnifiedContextApp',
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
const CONFLICTING_ID_SEGMENT = 'dropdown-';
2+
const SAFE_ID_SEGMENT = 'menu-';
3+
4+
const ATTRIBUTE_NAMES = ['id', 'aria-controls', 'aria-labelledby'] as const;
5+
const SELECTOR = ATTRIBUTE_NAMES.map((attribute) => `[${attribute}*="${CONFLICTING_ID_SEGMENT}"]`).join(
6+
', '
7+
);
8+
9+
let compatibilityObserver: MutationObserver | null = null;
10+
11+
function normalizeValue(value: string): string {
12+
return value.replace(/dropdown-menu-/g, SAFE_ID_SEGMENT).replace(/dropdown-/g, SAFE_ID_SEGMENT);
13+
}
14+
15+
function sanitizeAttribute(element: Element, attributeName: (typeof ATTRIBUTE_NAMES)[number]): void {
16+
const value = element.getAttribute(attributeName);
17+
if (!value || !value.includes(CONFLICTING_ID_SEGMENT)) {
18+
return;
19+
}
20+
21+
element.setAttribute(attributeName, normalizeValue(value));
22+
}
23+
24+
function sanitizeElement(element: Element): void {
25+
ATTRIBUTE_NAMES.forEach((attributeName) => {
26+
sanitizeAttribute(element, attributeName);
27+
});
28+
}
29+
30+
function sanitizeTree(root: ParentNode): void {
31+
root.querySelectorAll(SELECTOR).forEach((element) => {
32+
sanitizeElement(element);
33+
});
34+
}
35+
36+
export function enableDropdownIdCompatibility(doc: Document = document): void {
37+
if (compatibilityObserver) {
38+
sanitizeTree(doc);
39+
return;
40+
}
41+
42+
sanitizeTree(doc);
43+
44+
const observerRoot = doc.body ?? doc.documentElement;
45+
if (!observerRoot) {
46+
return;
47+
}
48+
49+
compatibilityObserver = new MutationObserver((mutations) => {
50+
mutations.forEach((mutation) => {
51+
if (mutation.type === 'attributes' && mutation.target instanceof Element) {
52+
const attributeName = mutation.attributeName;
53+
if (
54+
attributeName === 'id' ||
55+
attributeName === 'aria-controls' ||
56+
attributeName === 'aria-labelledby'
57+
) {
58+
sanitizeAttribute(mutation.target, attributeName);
59+
}
60+
return;
61+
}
62+
63+
mutation.addedNodes.forEach((node) => {
64+
if (!(node instanceof Element)) {
65+
return;
66+
}
67+
68+
sanitizeElement(node);
69+
sanitizeTree(node);
70+
});
71+
});
72+
});
73+
74+
compatibilityObserver.observe(observerRoot, {
75+
attributes: true,
76+
attributeFilter: [...ATTRIBUTE_NAMES],
77+
childList: true,
78+
subtree: true,
79+
});
80+
}

0 commit comments

Comments
 (0)