@equinor/fusion-react-styles
Version:
style lib inspired by @material-ui/styles
343 lines (342 loc) • 16.6 kB
JavaScript
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 };