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
27 changes: 26 additions & 1 deletion packages/toolbar/rslib.config.cdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ import { loadEnv } from '@rsbuild/core';
// Load environment variables with TOOLBAR_ prefix from .env files
const { publicVars } = loadEnv({ prefixes: ['TOOLBAR_'] });

/**
* Custom identifier function for vanilla-extract.
* Prefixes all generated class names with 'ldtb_' to make them uniquely
* identifiable. This enables reliable detection of toolbar styles for
* Shadow DOM isolation, preventing conflicts with host app CSS Modules.
*/
const toolbarIdentifiers = ({
hash,
debugId,
}: {
hash: string;
filePath: string;
debugId?: string;
packageName?: string;
}) => {
// In production, use short prefixed hash
// Format: ldtb_{hash} or ldtb_{debugId}_{hash}
const prefix = 'ldtb_';
return debugId ? `${prefix}${debugId}_${hash}` : `${prefix}${hash}`;
};

export default defineConfig({
source: {
entry: {
Expand Down Expand Up @@ -43,7 +64,11 @@ export default defineConfig({
plugins: [pluginReact()],
tools: {
rspack: {
plugins: [new VanillaExtractPlugin()],
plugins: [
new VanillaExtractPlugin({
identifiers: toolbarIdentifiers,
}),
],
optimization: {
splitChunks: false,
},
Expand Down
31 changes: 31 additions & 0 deletions packages/toolbar/rslib.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rslib/core';
import { VanillaExtractPlugin } from '@vanilla-extract/webpack-plugin';
import { loadEnv } from '@rsbuild/core';

// Load environment variables with TOOLBAR_ prefix from .env files
const { publicVars } = loadEnv({ prefixes: ['TOOLBAR_'] });

/**
* Custom identifier function for vanilla-extract.
* Prefixes all generated class names with 'ldtb_' to make them uniquely
* identifiable. This enables reliable detection of toolbar styles for
* Shadow DOM isolation, preventing conflicts with host app CSS Modules.
*/
const toolbarIdentifiers = ({
hash,
debugId,
}: {
hash: string;
filePath: string;
debugId?: string;
packageName?: string;
}) => {
// In development, include debugId for better debugging
// Format: ldtb_{debugId}_{hash} or ldtb_{hash}
const prefix = 'ldtb_';
return debugId ? `${prefix}${debugId}_${hash}` : `${prefix}${hash}`;
};

export default defineConfig({
source: {
entry: {
Expand Down Expand Up @@ -62,4 +84,13 @@ export default defineConfig({
},
},
plugins: [pluginReact()],
tools: {
rspack: {
plugins: [
new VanillaExtractPlugin({
identifiers: toolbarIdentifiers,
}),
],
},
},
});
5 changes: 5 additions & 0 deletions packages/toolbar/src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// Enable Shadow DOM support for React Aria/Stately components BEFORE any other imports
// This must be called early to ensure all LaunchPad components work correctly in Shadow DOM
import { enableShadowDOM } from '@react-stately/flags';
enableShadowDOM();

import { InitializationConfig } from '../types';
import mount from './mount';
import hydrateConfig from './utils/hydrateConfig';
Expand Down
138 changes: 78 additions & 60 deletions packages/toolbar/src/core/mount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,32 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { InitializationConfig } from '../types';
import { TOOLBAR_DOM_ID } from '../types/constants';

/**
* Module-level cache for toolbar styles.
* This persists across HMR cycles since it's outside the component lifecycle.
*/
const toolbarStyleCache = new Set<string>();
import {
createStyleInterceptor,
injectStylesIntoShadowRoot,
getCachedToolbarStyles,
cacheToolbarStyle,
isToolbarStyleContent,
} from './styles';

export default function mount(rootNode: HTMLElement, config: InitializationConfig) {
const cleanup: (() => void)[] = [];
let isMounted = true;

// Make sure host applications don't mount the toolbar multiple
// Make sure host applications don't mount the toolbar multiple times
if (document.getElementById(TOOLBAR_DOM_ID) != null) {
return () => {
cleanup.forEach((fn) => fn());
};
}

const { host, reactMount, observer } = buildDom();
const { host, reactMount, cleanupInterceptor } = buildDom();
cleanup.push(cleanupInterceptor);

const reactRoot = createRoot(reactMount);

// Dynamically import toolbar to capture style injection timing
// The style interceptor set up in buildDom() will redirect any injected styles
import('./ui/Toolbar/LaunchDarklyToolbar').then((module) => {
if (!isMounted) return;
const { LaunchDarklyToolbar } = module;
Expand Down Expand Up @@ -53,8 +56,6 @@ export default function mount(rootNode: HTMLElement, config: InitializationConfi
});

cleanup.push(() => {
isMounted = false;
observer.disconnect();
// `setTimeout` helps to avoid "Attempted to synchronously unmount a root while React was already rendering."
setTimeout(() => reactRoot.unmount(), 0);
});
Expand All @@ -67,7 +68,13 @@ export default function mount(rootNode: HTMLElement, config: InitializationConfi
};
}

function buildDom() {
interface DomElements {
host: HTMLDivElement;
reactMount: HTMLDivElement;
cleanupInterceptor: () => void;
}

function buildDom(): DomElements {
const host = document.createElement('div');
host.id = TOOLBAR_DOM_ID;
host.style.inset = '0';
Expand All @@ -81,64 +88,79 @@ function buildDom() {
throw new Error('[LaunchDarkly Toolbar] Failed to create shadow root');
}

const reactMount = document.createElement('div');
// Set up synchronous style interception BEFORE any toolbar imports
// This prevents toolbar styles from ever appearing in document.head
const cleanupInterceptor = createStyleInterceptor(shadowRoot);

// Snapshot existing styles BEFORE the toolbar component loads
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
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);
}
}
// Inject any existing LaunchPad styles that the toolbar might need
// (e.g., if the host app also uses LaunchPad and has already loaded tokens)
injectExistingLaunchPadStyles(shadowRoot);

// Restore cached toolbar styles from previous mounts (HMR support)
// These styles were removed from document.head on previous mount but cached
toolbarStyleCache.forEach((cachedContent) => {
const style = document.createElement('style');
style.textContent = cachedContent;
shadowRoot.appendChild(style);
});
const cachedStyles = getCachedToolbarStyles();
if (cachedStyles.length > 0) {
const combinedCached = cachedStyles.join('\n');
injectStylesIntoShadowRoot(shadowRoot, combinedCached);
}

const reactMount = document.createElement('div');
reactMount.dataset.name = 'react-mount';
reactMount.id = 'ld-toolbar-react-mount';
shadowRoot.appendChild(reactMount);

// Watch for NEW styles injected by the toolbar and redirect them to shadow root
// This prevents toolbar's LaunchPad styles from overriding host app custom styles
// Set up a backup MutationObserver for edge cases where interception might miss styles
// (e.g., styles injected via mechanisms other than appendChild/insertBefore)
setupBackupObserver(shadowRoot);

return { host, reactMount, cleanupInterceptor };
}

/**
* Copies existing LaunchPad styles from document.head to the Shadow DOM.
* This handles cases where the host app uses LaunchPad and has already loaded
* design tokens that the toolbar components depend on.
*/
function injectExistingLaunchPadStyles(shadowRoot: ShadowRoot): void {
if (!document.head) return;

const existingStyles = Array.from(document.head.querySelectorAll('style'))
.filter((styleEl) => {
const content = styleEl.textContent || '';
// Only copy LaunchPad token styles (CSS custom properties)
// Don't copy component styles that might conflict
return content.includes('--lp-') && !content.includes('ldtb_');
})
.map((styleEl) => styleEl.textContent || '')
.join('\n');

if (existingStyles) {
injectStylesIntoShadowRoot(shadowRoot, existingStyles);
}
}

/**
* Sets up a backup MutationObserver to catch any styles that slip through
* the synchronous interception (edge cases like innerHTML assignment).
*/
function setupBackupObserver(shadowRoot: ShadowRoot): void {
if (!document.head) return;

const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === 'STYLE') {
const styleEl = node as HTMLStyleElement;
const content = styleEl.textContent || '';

// Check if this is a NEW LaunchPad/toolbar style (not from host app)
const isNewToolbarStyle =
!existingStylesSnapshot.has(content) && (content.includes('--lp-') || content.includes('_'));
// Check if this is a toolbar style that slipped through
if (isToolbarStyleContent(content)) {
// Cache for HMR support
cacheToolbarStyle(content);

if (isNewToolbarStyle) {
// Cache the style content for HMR support
toolbarStyleCache.add(content);
// Move to shadow root
injectStylesIntoShadowRoot(shadowRoot, content);

// Copy to shadow root so toolbar still works
const shadowStyleEl = document.createElement('style');
shadowStyleEl.textContent = content;
shadowRoot.insertBefore(shadowStyleEl, reactMount);

// Remove from document.head to prevent overriding host app styles
// We can remove immediately since we've already copied to shadow root
// Remove from document.head
try {
styleEl.remove();
} catch (error) {
Expand All @@ -150,13 +172,9 @@ function buildDom() {
});
});

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

// Keep observer running longer for HMR scenarios where styles may be re-injected
setTimeout(() => observer.disconnect(), 5000);
observer.observe(document.head, { childList: true });

return { host, reactMount, observer };
// Keep observer running for HMR scenarios, but disconnect after a reasonable time
// to avoid memory leaks in production
setTimeout(() => observer.disconnect(), 10000);
}
48 changes: 48 additions & 0 deletions packages/toolbar/src/core/styles/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Style isolation constants for Shadow DOM.
*
* This module provides unique identifiers and detection patterns for toolbar styles,
* enabling reliable isolation from host application stylesheets.
*/

/**
* Unique prefix for all toolbar vanilla-extract class names.
* This is configured in rslib.config.ts and rslib.config.cdn.ts
*/
export const TOOLBAR_CLASS_PREFIX = 'ldtb_';

/**
* CSS comment marker injected into toolbar stylesheets.
* Used to identify toolbar styles during runtime interception.
*/
export const TOOLBAR_STYLE_MARKER = '/* LD_TOOLBAR_STYLES */';

/**
* CSS variable prefixes used by LaunchDarkly design systems.
* Add new prefixes here as they are introduced.
*/
export const LAUNCHPAD_TOKEN_PREFIXES = [
'--lp-', // LaunchPad design system tokens
] as const;

/**
* Checks if CSS content belongs to the toolbar based on known markers.
* This is used for reliable style interception without false positives.
*
* @param content - CSS content to check
* @returns true if the content is toolbar CSS
*/
export function isToolbarStyleContent(content: string): boolean {
// Check for explicit marker (most reliable)
if (content.includes(TOOLBAR_STYLE_MARKER)) {
return true;
}

// Check for toolbar class prefix (vanilla-extract generated)
if (content.includes(TOOLBAR_CLASS_PREFIX)) {
return true;
}

// Check for LaunchPad tokens (design system variables)
return LAUNCHPAD_TOKEN_PREFIXES.some((prefix) => content.includes(prefix));
}
14 changes: 14 additions & 0 deletions packages/toolbar/src/core/styles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export {
TOOLBAR_CLASS_PREFIX,
TOOLBAR_STYLE_MARKER,
LAUNCHPAD_TOKEN_PREFIXES,
isToolbarStyleContent,
} from './constants';

export {
injectStylesIntoShadowRoot,
createStyleInterceptor,
cacheToolbarStyle,
getCachedToolbarStyles,
clearToolbarStyleCache,
} from './shadowDomStyles';
Loading
Loading