UNPKG

@equinor/fusion-react-styles

Version:
343 lines (342 loc) 16.6 kB
import { getDynamicStyles } from 'jss'; /** * Simple hash function for style content to detect changes during hot-reload * Handles function-based styles by including function references in the hash * Only used in development mode * * @internal This is an internal utility for hot-reload support * @param styles - Style rules to hash * @returns Hash string for comparison, or undefined in production */ function hashStyles(styles) { if (process.env.NODE_ENV !== 'development') { return undefined; } try { // Create a replacer that handles functions // Functions can't be JSON stringified, so we include their string representation const replacer = (_key, value) => { if (typeof value === 'function') { // Include function reference in hash for function-based styles // This ensures changes to function implementations are detected during hot-reload return `__FUNCTION__${value.toString()}`; } return value; }; // Create a normalized version of styles for hashing // Sort keys to ensure consistent hashing regardless of object key order const sortedKeys = Object.keys(styles).sort(); const sortedStyles = {}; for (const key of sortedKeys) { sortedStyles[key] = styles[key]; } const normalized = JSON.stringify(sortedStyles, replacer); return normalized; } catch { // If JSON.stringify fails (e.g., circular references), fallback to object reference // Fallback ensures we still get some hash even with problematic objects return String(styles); } } /** * Simple sheet manager for caching and reusing JSS stylesheets * * This manager implements a caching strategy to avoid recreating stylesheets * for the same styles/theme combination. It separates static and dynamic styles * for optimal performance. * * Key features: * - Reference counting for cleanup * - Separation of static and dynamic styles * - Caching based on name + theme combination * - Automatic cleanup when sheets are no longer referenced * - Hot-reload support: detects style changes and updates existing sheets */ class SheetManager { /** Cache of stylesheets keyed by name-theme combination */ sheets = new Map(); /** Counter for generating unique instance IDs for dynamic sheets */ instanceCounter = 0; /** Map of style hashes to cache keys for hot-reload detection (dev only) */ stylesHashToKey = process.env.NODE_ENV === 'development' ? new Map() : undefined; /** * Gets or creates a JSS stylesheet for the given styles * * This method implements a caching strategy: * 1. Creates a cache key from cacheKey + theme (or name + theme if cacheKey not provided) * 2. If cached, increments reference count and returns cached classes * 3. If not cached, creates static sheet and extracts dynamic styles * 4. For dynamic styles, creates a linked sheet that updates with props * 5. Merges static and dynamic class names * * @param styles - Style rules object * @param theme - Theme object * @param name - Name prefix for the JSS stylesheet (used by JSS) * @param jss - JSS instance * @param generateClassName - Class name generator function * @param props - Props for dynamic styles (optional) * @param cacheKey - Optional cache key (if not provided, uses name) * @param isFirstRender - Whether this is the first render of the component (increments refs) * @returns Result containing class names, instance ID (if dynamic), and cache key */ getOrCreateSheet(styles, theme, name, jss, generateClassName, props = {}, isFirstRender = false) { // Create cache key from cacheKey (or name if not provided) and theme (stringified) const key = `${name}-${JSON.stringify(theme)}`; const stylesHash = hashStyles(styles); let sheetEntry = this.sheets.get(key); if (process.env.NODE_ENV === 'development' && stylesHash) { // Development-only hot-reload detection logic // Check if styles changed for existing entry (hot-reload scenario) if (sheetEntry && sheetEntry.stylesHash !== stylesHash) { // Styles changed during hot-reload - update the existing sheet // Detach old static sheet sheetEntry.staticSheet.detach(); // Clean up all dynamic sheets for (const dynamicSheet of sheetEntry.dynamicSheets.values()) { dynamicSheet.detach(); } sheetEntry.dynamicSheets.clear(); // Create new static sheet with updated styles // JSS types don't match our StyleRules type exactly, but this is type-safe // StyleRules is compatible with JSS's expected input format // @ts-expect-error JSS types don't match our StyleRules type exactly - this is safe const staticSheet = jss.createStyleSheet(styles, { link: false, generateId: generateClassName, name, theme, meta: JSON.stringify({ name }), }); staticSheet.attach(); // Update entry with new styles sheetEntry.staticSheet = staticSheet; sheetEntry.staticSheetClasses = staticSheet.classes; // getDynamicStyles accepts our StyleRules type, JSS type mismatch is cosmetic // @ts-expect-error JSS types don't match our StyleRules type exactly - this is safe sheetEntry.dynamicStyles = getDynamicStyles(styles); sheetEntry.stylesHash = stylesHash; sheetEntry.originalStyles = styles; // Update hash mapping this.stylesHashToKey?.set(stylesHash, key); } else if (!sheetEntry) { // Check if we have a sheet with the same styles but different key (hot-reload with new scopeId) const existingKey = this.stylesHashToKey?.get(stylesHash); if (existingKey && existingKey !== key) { // Found existing sheet with same styles but different key const existingEntry = this.sheets.get(existingKey); if (existingEntry && existingEntry.stylesHash === stylesHash) { // Create new entry that shares the same static sheet but has its own refs // This handles the case where scopeId changes during hot-reload // Both entries share the staticSheet (same styles), but each maintains its own ref count // This prevents memory leaks while allowing hot-reload to work correctly sheetEntry = { refs: 0, staticSheet: existingEntry.staticSheet, staticSheetClasses: existingEntry.staticSheetClasses, dynamicStyles: existingEntry.dynamicStyles, dynamicSheets: new Map(), // Each entry has its own dynamic sheets map stylesHash, originalStyles: styles, }; this.sheets.set(key, sheetEntry); // Update hash mapping to point to the new key (for future lookups) // Update mapping so future hot-reloads can find the sheet by hash this.stylesHashToKey?.set(stylesHash, key); } } } } if (!sheetEntry) { // Create static sheet (styles that don't depend on props) // We use type assertions because JSS types don't perfectly match our StyleRules type // StyleRules is compatible with JSS's expected input format, so this is type-safe // @ts-expect-error JSS types don't match our StyleRules type exactly - this is safe const staticSheet = jss.createStyleSheet(styles, { link: false, // Static sheets don't need to be linked (can't update) generateId: generateClassName, name, theme, meta: JSON.stringify({ name }), }); staticSheet.attach(); sheetEntry = { refs: 0, staticSheet, staticSheetClasses: staticSheet.classes, // Extract dynamic styles (functions that depend on props) // getDynamicStyles accepts our StyleRules type, JSS type mismatch is cosmetic // @ts-expect-error JSS types don't match our StyleRules type exactly - this is safe dynamicStyles: getDynamicStyles(styles), dynamicSheets: new Map(), ...(process.env.NODE_ENV === 'development' && stylesHash ? { stylesHash, originalStyles: styles } : {}), }; this.sheets.set(key, sheetEntry); if (process.env.NODE_ENV === 'development' && stylesHash) { this.stylesHashToKey?.set(stylesHash, key); } } // Only increment reference count on first render (when component mounts) // Refs track how many component instances are using this sheet // Incrementing only on first render prevents double-counting during re-renders if (isFirstRender) { sheetEntry.refs += 1; } // Handle dynamic styles (if any) if (sheetEntry.dynamicStyles) { // Generate unique instance ID for this component's dynamic sheet // Each component instance gets its own dynamic sheet for prop-based styles const instanceId = `${this.instanceCounter++}`; // Create linked sheet for dynamic styles (can be updated) // Linked sheets can be updated when props change, unlike static sheets const dynamicSheet = jss.createStyleSheet(sheetEntry.dynamicStyles, { link: true, // Linked sheets can be updated when props change generateId: generateClassName, name, theme, meta: `${name}-dynamic-${instanceId}`, }); // Update dynamic styles with current props // This resolves function values in dynamic styles based on current props dynamicSheet.update(props); dynamicSheet.attach(); // Store the dynamic sheet for cleanup later // Each instanceId maps to a specific component's dynamic sheet sheetEntry.dynamicSheets.set(instanceId, dynamicSheet); // Merge static and dynamic class names (dynamic takes precedence) // Dynamic styles override static styles if both define the same class key return { classes: { ...sheetEntry.staticSheetClasses, ...dynamicSheet.classes, }, instanceId, cacheKey: key, }; } // No dynamic styles, return static classes only return { classes: sheetEntry.staticSheetClasses, cacheKey: key, }; } /** * Removes a dynamic sheet instance without decrementing ref count * Used when component props change and a new dynamic sheet is created * * @param key - Cache key for the stylesheet * @param instanceId - Instance ID for the dynamic sheet to remove */ removeDynamicSheet(key, instanceId) { const sheetEntry = this.sheets.get(key); if (!sheetEntry) { return; } // Clean up dynamic sheet if it exists // This is called when component props change, before creating a new dynamic sheet // Prevents memory leaks from orphaned dynamic sheets if (sheetEntry.dynamicSheets.has(instanceId)) { const dynamicSheet = sheetEntry.dynamicSheets.get(instanceId); if (dynamicSheet) { dynamicSheet.detach(); sheetEntry.dynamicSheets.delete(instanceId); } } } /** * Removes a reference to a sheet and cleans up if no longer needed * * @param key - Cache key for the stylesheet * @param instanceId - Optional instance ID for dynamic sheet cleanup */ removeSheet(key, instanceId) { const sheetEntry = this.sheets.get(key); if (!sheetEntry) { return; } // Clean up dynamic sheet if instance ID is provided if (instanceId && sheetEntry.dynamicSheets.has(instanceId)) { const dynamicSheet = sheetEntry.dynamicSheets.get(instanceId); if (dynamicSheet) { dynamicSheet.detach(); sheetEntry.dynamicSheets.delete(instanceId); } } // Decrement reference count sheetEntry.refs -= 1; // If no more references, detach static sheet and remove from cache if (sheetEntry.refs <= 0) { // Check if any other entries are sharing this static sheet before detaching // Hot-reload can create multiple entries sharing the same static sheet // Only detach when truly unused to prevent breaking other components let hasOtherReferences = false; for (const [otherKey, otherEntry] of this.sheets.entries()) { if (otherKey !== key && otherEntry.staticSheet === sheetEntry.staticSheet) { hasOtherReferences = true; break; } } // Only detach static sheet if no other entries are using it // This handles hot-reload scenarios where multiple entries share sheets if (!hasOtherReferences) { sheetEntry.staticSheet.detach(); } // Detach any remaining dynamic sheets (these are always per-entry, so safe to detach) // Dynamic sheets are never shared between entries, so always safe to detach for (const dynamicSheet of sheetEntry.dynamicSheets.values()) { dynamicSheet.detach(); } // Remove from cache this.sheets.delete(key); // Clean up hash mapping if this was the last sheet with this hash (dev only) // Hash mapping is used for hot-reload detection, only needed in development if (process.env.NODE_ENV === 'development' && sheetEntry.stylesHash && this.stylesHashToKey) { const hashKey = this.stylesHashToKey.get(sheetEntry.stylesHash); if (hashKey === key) { // Check if there are any other entries with the same hash before removing // Multiple entries can share the same hash during hot-reload let hasOtherHashEntries = false; for (const otherEntry of this.sheets.values()) { if (otherEntry.stylesHash === sheetEntry.stylesHash) { hasOtherHashEntries = true; break; } } if (!hasOtherHashEntries) { this.stylesHashToKey.delete(sheetEntry.stylesHash); } } } } } /** * Clears all sheets * * This method detaches all sheets and clears the cache. * Useful for testing or full application teardown. * * @internal This is primarily used for testing and cleanup scenarios */ clear() { // Detach all sheets before clearing // Properly detach all sheets to remove them from the DOM for (const sheetEntry of this.sheets.values()) { sheetEntry.staticSheet.detach(); for (const dynamicSheet of sheetEntry.dynamicSheets.values()) { dynamicSheet.detach(); } } this.sheets.clear(); if (process.env.NODE_ENV === 'development') { this.stylesHashToKey?.clear(); } } } /** * Default sheet manager instance */ export const defaultSheetManager = new SheetManager(); export { SheetManager };