Skip to content
Open
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
39 changes: 24 additions & 15 deletions packages/toolbar/src/core/mount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,24 +61,30 @@ function buildDom() {
host.style.zIndex = '2147400100';

const shadowRoot = host.attachShadow({ mode: 'open' });
if (!shadowRoot) {
throw new Error('[LaunchDarkly Toolbar] Failed to create shadow root');
}

const reactMount = document.createElement('div');

// Snapshot existing styles BEFORE the toolbar component loads
const existingStylesSnapshot = new Set(
Array.from(document.head.querySelectorAll('style')).map((el) => el.textContent || ''),
);
const existingStylesSnapshot = document.head
? new Set(Array.from(document.head.querySelectorAll('style')).map((el) => el.textContent || ''))
: new Set();

// Copy existing LaunchPad styles (including Gonfalon's) to shadow root
// so toolbar has the base styles it needs
const existingStyles = Array.from(document.head.querySelectorAll('style'))
.filter((styleEl) => styleEl.textContent?.includes('--lp-') || styleEl.textContent?.includes('_'))
.map((styleEl) => styleEl.textContent || '')
.join('\n');

if (existingStyles) {
const style = document.createElement('style');
style.textContent = existingStyles;
shadowRoot.appendChild(style);
if (document.head) {
const existingStyles = Array.from(document.head.querySelectorAll('style'))
.filter((styleEl) => styleEl.textContent?.includes('--lp-') || styleEl.textContent?.includes('_'))
.map((styleEl) => styleEl.textContent || '')
.join('\n');

if (existingStyles) {
const style = document.createElement('style');
style.textContent = existingStyles;
shadowRoot.appendChild(style);
}
}

reactMount.dataset.name = 'react-mount';
Expand Down Expand Up @@ -108,16 +114,19 @@ function buildDom() {
// We can remove immediately since we've already copied to shadow root
try {
styleEl.remove();
} catch {
// Ignore if already removed
} catch (error) {
console.warn('[LaunchDarkly Toolbar] Failed to remove style element from document.head:', error);
}
}
}
});
});
});

observer.observe(document.head, { childList: true });
// Only observe document.head if it exists
if (document.head) {
observer.observe(document.head, { childList: true });
}

// Stop observing after 500ms (toolbar should be fully loaded by then)
setTimeout(() => observer.disconnect(), 500);
Expand Down
262 changes: 262 additions & 0 deletions packages/toolbar/src/core/tests/mount.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { expect, test, describe, vi, beforeEach, afterEach } from 'vitest';
import mount from '../mount';
import type { InitializationConfig } from '../../types';

describe('mount', () => {
let rootNode: HTMLElement;
let mockConfig: InitializationConfig;

beforeEach(() => {
// Create a fresh root node for each test
rootNode = document.createElement('div');
document.body.appendChild(rootNode);

// Mock config
mockConfig = {
baseUrl: 'https://app.launchdarkly.com',
projectKey: 'test-project',
position: 'top-right',
};
});

afterEach(() => {
// Clean up DOM
document.body.innerHTML = '';
vi.clearAllMocks();
});

describe('Basic Mounting', () => {
test('creates toolbar host element with correct id', () => {
mount(rootNode, mockConfig);

const toolbarHost = document.getElementById('ld-toolbar');
expect(toolbarHost).not.toBeNull();
expect(toolbarHost?.tagName).toBe('DIV');
});

test('creates shadow root for style isolation', () => {
mount(rootNode, mockConfig);

const toolbarHost = document.getElementById('ld-toolbar');
expect(toolbarHost?.shadowRoot).not.toBeNull();
expect(toolbarHost?.shadowRoot?.mode).toBe('open');
});

test('creates react mount point inside shadow root', () => {
mount(rootNode, mockConfig);

const toolbarHost = document.getElementById('ld-toolbar');
const reactMount = toolbarHost?.shadowRoot?.getElementById('ld-toolbar-react-mount');

expect(reactMount).not.toBeNull();
expect(reactMount?.dataset.name).toBe('react-mount');
});

test('prevents multiple mounts with same DOM id', () => {
const cleanup1 = mount(rootNode, mockConfig);
const cleanup2 = mount(rootNode, mockConfig);

const toolbars = document.querySelectorAll('#ld-toolbar');
expect(toolbars.length).toBe(1);

cleanup1();
cleanup2();
});

test('applies correct styles to host element', () => {
mount(rootNode, mockConfig);

const toolbarHost = document.getElementById('ld-toolbar') as HTMLElement;
expect(toolbarHost.style.position).toBe('absolute');
expect(toolbarHost.style.zIndex).toBe('2147400100');
expect(toolbarHost.style.inset).toBe('0'); // Browsers normalize '0px' to '0'
});
});

describe('Style Isolation', () => {
test('copies existing LaunchPad styles to shadow root', () => {
// Add some LaunchPad styles to document.head
const styleEl = document.createElement('style');
styleEl.textContent = '.test { --lp-color-primary: blue; }';
document.head.appendChild(styleEl);

mount(rootNode, mockConfig);

const toolbarHost = document.getElementById('ld-toolbar');
const shadowStyles = toolbarHost?.shadowRoot?.querySelector('style');

expect(shadowStyles).not.toBeNull();
expect(shadowStyles?.textContent).toContain('--lp-');

styleEl.remove();
});

test('intercepts new toolbar styles and moves to shadow root', async () => {
const cleanup = mount(rootNode, mockConfig);

// Simulate toolbar injecting a style
const newStyle = document.createElement('style');
newStyle.textContent = '.button_abc123 { color: red; }';
document.head.appendChild(newStyle);

// Wait for MutationObserver to process
await new Promise((resolve) => setTimeout(resolve, 100));

const toolbarHost = document.getElementById('ld-toolbar');
const shadowStyles = Array.from(toolbarHost?.shadowRoot?.querySelectorAll('style') || []);
const hasButtonStyle = shadowStyles.some((style) => style.textContent?.includes('button_abc123'));

expect(hasButtonStyle).toBe(true);

// Verify style was removed from document.head
const headStyles = Array.from(document.head.querySelectorAll('style'));
const stillInHead = headStyles.some((style) => style.textContent?.includes('button_abc123'));
expect(stillInHead).toBe(false);

cleanup();
});

test('detects LaunchPad styles with --lp- prefix', async () => {
const cleanup = mount(rootNode, mockConfig);

const newStyle = document.createElement('style');
newStyle.textContent = ':root { --lp-spacing: 8px; }';
document.head.appendChild(newStyle);

// Wait for MutationObserver to process
await new Promise((resolve) => setTimeout(resolve, 100));

const toolbarHost = document.getElementById('ld-toolbar');
const shadowStyles = Array.from(toolbarHost?.shadowRoot?.querySelectorAll('style') || []);
const hasLpStyle = shadowStyles.some((style) => style.textContent?.includes('--lp-spacing'));

expect(hasLpStyle).toBe(true);

cleanup();
});

test('does not intercept host app styles without LaunchPad', async () => {
const cleanup = mount(rootNode, mockConfig);

const hostAppStyle = document.createElement('style');
hostAppStyle.textContent = '.my-app-class { color: blue; }';
document.head.appendChild(hostAppStyle);

// Wait for MutationObserver to process
await new Promise((resolve) => setTimeout(resolve, 100));

// Should still be in document.head
const headStyles = Array.from(document.head.querySelectorAll('style'));
const stillInHead = headStyles.some((style) => style.textContent?.includes('my-app-class'));
expect(stillInHead).toBe(true);

cleanup();
hostAppStyle.remove();
});

test('handles duplicate styles gracefully', async () => {
// Add existing style to head first
const existingStyle = document.createElement('style');
existingStyle.textContent = '.existing { --lp-color: blue; }';
document.head.appendChild(existingStyle);

const cleanup = mount(rootNode, mockConfig);

// Try adding the same style again (simulating toolbar re-injecting)
const duplicateStyle = document.createElement('style');
duplicateStyle.textContent = '.existing { --lp-color: blue; }';
document.head.appendChild(duplicateStyle);

await new Promise((resolve) => setTimeout(resolve, 100));

// Should handle gracefully without errors
const toolbarHost = document.getElementById('ld-toolbar');
const shadowStyles = Array.from(toolbarHost?.shadowRoot?.querySelectorAll('style') || []);

// Should have styles but no errors thrown
expect(shadowStyles.length).toBeGreaterThan(0);

existingStyle.remove();
cleanup();
});
});

describe('Cleanup', () => {
test('cleanup function removes toolbar from DOM', () => {
const cleanup = mount(rootNode, mockConfig);

expect(document.getElementById('ld-toolbar')).not.toBeNull();

cleanup();

expect(document.getElementById('ld-toolbar')).toBeNull();
});

test('cleanup function can be called multiple times safely', () => {
const cleanup = mount(rootNode, mockConfig);

cleanup();
expect(() => cleanup()).not.toThrow();
});
});

describe('Edge Cases and Error Handling', () => {
test('handles missing document.head gracefully without throwing', () => {
const originalHead = document.head;
let didThrow = false;

try {
// @ts-ignore - testing edge case
Object.defineProperty(document, 'head', {
get: () => null,
configurable: true,
});

mount(rootNode, mockConfig);
} catch {
didThrow = true;
} finally {
// Restore
Object.defineProperty(document, 'head', {
get: () => originalHead,
configurable: true,
});
}

// Should not throw even with null document.head
expect(didThrow).toBe(false);
});

test('handles style removal error gracefully', async () => {
const cleanup = mount(rootNode, mockConfig);

const newStyle = document.createElement('style');
newStyle.textContent = '.test_abc123 { color: red; }';
document.head.appendChild(newStyle);

// Spy on console.warn to verify error handling
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

// Make remove throw an error
const originalRemove = newStyle.remove;
newStyle.remove = vi.fn(() => {
throw new Error('Cannot remove');
});

// Wait for MutationObserver to process
await new Promise((resolve) => setTimeout(resolve, 100));

// Verify console.warn was called with error message
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[LaunchDarkly Toolbar] Failed to remove style element'),
expect.any(Error),
);

// Restore and cleanup
warnSpy.mockRestore();
newStyle.remove = originalRemove;
newStyle.remove();
cleanup();
});
});
});
27 changes: 27 additions & 0 deletions packages/toolbar/src/react/lazyLoadToolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,32 @@ export default async function lazyLoadToolbar(signal: AbortSignal, url: string):
}

async function lazyLoad(signal: AbortSignal, url: string): Promise<void> {
// Check if a script with this URL already exists
const existingScript = document.querySelector(`script[src="${url}"]`) as HTMLScriptElement | null;

if (existingScript) {
// If script already exists and is loaded, return immediately
if (existingScript.dataset.loaded === 'true') {
return Promise.resolve();
}

// If script exists but is still loading, wait for it to complete
return new Promise<void>((resolve, reject) => {
existingScript.addEventListener('load', () => {
if (!signal.aborted) {
existingScript.dataset.loaded = 'true';
resolve();
}
});
existingScript.addEventListener('error', (error) => {
if (!signal.aborted) {
reject(error);
}
});
});
}

// Create new script element
const script = document.createElement('script');
script.src = url;
script.crossOrigin = 'anonymous';
Expand All @@ -34,6 +60,7 @@ async function lazyLoad(signal: AbortSignal, url: string): Promise<void> {
const waitForLoad = new Promise<void>((resolve, reject) => {
script.addEventListener('load', () => {
if (!signal.aborted) {
script.dataset.loaded = 'true';
resolve();
}
});
Expand Down
Loading