UNPKG

@rws-framework/client

Version:

This package provides the core client-side framework for Realtime Web Suit (RWS), enabling modular, asynchronous web components, state management, and integration with backend services. It is located in `.dev/client`.

313 lines (260 loc) 11.7 kB
import { domEvents } from '../events'; import IndexedDBService, { IndexedDBServiceInstance } from '../services/IndexedDBService'; import RWSViewComponent from './_component'; type CSSInjectMode = 'adopted' | 'legacy' | 'both' | 'style-element'; const _DEFAULT_INJECT_CSS_CACHE_LIMIT_DAYS = 1; interface ICSSInjectionOptions { mode?: CSSInjectMode; maxDaysExp?: number; } interface ICSSInjectionComponent { componentElement?: RWSViewComponent; shadowRoot: ShadowRoot | null; indexedDBService: IndexedDBServiceInstance; $emit(eventName: string): void; } export class CSSInjectionManager { private static CACHED_STYLES: Map<string, CSSStyleSheet> = new Map(); private static STYLES_OWNER_COMPONENT: ICSSInjectionComponent | null = null; private static LOADING_PROMISES: Map<string, Promise<CSSStyleSheet>> = new Map(); static getCachedStyles(styleLinks: string[]): CSSStyleSheet[] { return styleLinks .filter(link => CSSInjectionManager.CACHED_STYLES.has(link)) .map(link => CSSInjectionManager.CACHED_STYLES.get(link)!); } static hasCachedStyles(styleLinks: string[]): boolean { return styleLinks.every(link => CSSInjectionManager.CACHED_STYLES.has(link)); } static getStylesOwnerComponent(): ICSSInjectionComponent | null { return CSSInjectionManager.STYLES_OWNER_COMPONENT; } static clearCachedStyles(): void { CSSInjectionManager.CACHED_STYLES.clear(); CSSInjectionManager.STYLES_OWNER_COMPONENT = null; CSSInjectionManager.LOADING_PROMISES.clear(); } static async injectStyles( component: ICSSInjectionComponent, styleLinks: string[], options: ICSSInjectionOptions = {} ): Promise<void> { if (!component.shadowRoot) { throw new Error('Component must have a shadow root for CSS injection'); } // Only proceed if there are actually styles to inject if (!styleLinks || styleLinks.length === 0) { return; } // Add initial transition styles to host element only when injecting styles const transitionSheet = new CSSStyleSheet(); await transitionSheet.replace(` :host { opacity: 0; transition: opacity 0.3s ease-in-out; } `); component.shadowRoot.adoptedStyleSheets = [ transitionSheet, ...component.shadowRoot.adoptedStyleSheets, ]; const doneAdded = await CSSInjectionManager.addStyleSheets(component, styleLinks, options); if (doneAdded) { // Set opacity to 1 to fade in the component const opacitySheet = new CSSStyleSheet(); await opacitySheet.replace(` :host { opacity: 1 !important; } `); component.shadowRoot.adoptedStyleSheets = [ opacitySheet, ...component.shadowRoot.adoptedStyleSheets, ]; component.$emit(domEvents.loadedLinkedStyles); } } private static async addStyleSheets(component: ICSSInjectionComponent, styleLinks: string[], options: ICSSInjectionOptions = {}): Promise<boolean> { const { mode = 'adopted', maxDaysExp } = options; let adoptedSheets: CSSStyleSheet[] = []; let doneAdded = false; // Check if we already have cached styles from the owner component const cachedSheets: CSSStyleSheet[] = []; const uncachedLinks: string[] = []; let hasCached = false; for (const styleLink of styleLinks) { if (CSSInjectionManager.CACHED_STYLES.has(styleLink)) { cachedSheets.push(CSSInjectionManager.CACHED_STYLES.get(styleLink)!); hasCached = true; } else { uncachedLinks.push(styleLink); } } if(hasCached){ CSSInjectionManager.setStylesOwner(component); } // If we have cached styles, use them immediately if (cachedSheets.length > 0) { adoptedSheets.push(...cachedSheets); doneAdded = true; } // Only process uncached styles if (uncachedLinks.length > 0) { // Set this component as the owner if no owner exists yet CSSInjectionManager.setStylesOwner(component); const dbName = 'css-cache'; const storeName = 'styles'; const db = await component.indexedDBService.openDB(dbName, storeName); const maxAgeMs = 1000 * 60 * 60 * 24; // 24h const maxDaysAge = maxDaysExp ? maxDaysExp : _DEFAULT_INJECT_CSS_CACHE_LIMIT_DAYS; const maxAgeDays = maxAgeMs * maxDaysAge; for (const styleLink of uncachedLinks) { const linkMode = Object.keys(RWSViewComponent.FORCE_INJECT_MODE_PER_LINK).includes(styleLink) ? RWSViewComponent.FORCE_INJECT_MODE_PER_LINK[styleLink] : mode; // If another component is already loading this style, wait for it instead of fetching again if (CSSInjectionManager.LOADING_PROMISES.has(styleLink)) { const sheet = await CSSInjectionManager.LOADING_PROMISES.get(styleLink)!; adoptedSheets.push(sheet); doneAdded = true; continue; } const loadPromise = new Promise<CSSStyleSheet>(async (resolve, reject) => { try { if (linkMode === 'legacy') { await CSSInjectionManager.injectLegacyStyle(component, styleLink, () => { doneAdded = true; resolve(null); }); } else if (linkMode === 'style-element') { await CSSInjectionManager.injectStyleElement(component, styleLink, db, storeName, maxAgeDays); doneAdded = true; resolve(null); } else if (linkMode === 'adopted') { const sheet = await CSSInjectionManager.injectAdoptedStyle(component, styleLink, db, storeName, maxAgeDays); adoptedSheets.push(sheet); doneAdded = true; resolve(sheet); } else if (linkMode === 'both') { const [sheet] = await Promise.all([ CSSInjectionManager.injectAdoptedStyle(component, styleLink, db, storeName, maxAgeDays), new Promise<void>((resolveLegacy) => { CSSInjectionManager.injectLegacyStyle(component, styleLink, () => { resolveLegacy(); }); }) ]); adoptedSheets.push(sheet); doneAdded = true; resolve(sheet); } } catch (error) { console.error(`Failed to inject styles for ${styleLink}:`, error); reject(error); } }); CSSInjectionManager.LOADING_PROMISES.set(styleLink, loadPromise); try { await loadPromise; } catch { // already logged inside } finally { CSSInjectionManager.LOADING_PROMISES.delete(styleLink); } } doneAdded = true; } if (adoptedSheets.length) { component.shadowRoot.adoptedStyleSheets = [ ...adoptedSheets, ...component.shadowRoot.adoptedStyleSheets, ]; doneAdded = true; } return doneAdded; } private static async injectLegacyStyle( component: ICSSInjectionComponent, styleLink: string, onLoad: () => void ): Promise<void> { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = styleLink; link.onload = onLoad; component.shadowRoot!.appendChild(link); } private static async injectStyleElement( component: ICSSInjectionComponent, styleLink: string, db: IDBDatabase, storeName: string, maxAgeDays: number ): Promise<void> { const cssText = await CSSInjectionManager.getCachedOrFetchCSS(component, styleLink, db, storeName, maxAgeDays); if (component.componentElement) { const styleElement = document.createElement('style'); styleElement.textContent = cssText; component.componentElement.appendChild(styleElement); } } private static async injectAdoptedStyle( component: ICSSInjectionComponent, styleLink: string, db: IDBDatabase, storeName: string, maxAgeDays: number ): Promise<CSSStyleSheet> { const cssText = await CSSInjectionManager.getCachedOrFetchCSS(component, styleLink, db, storeName, maxAgeDays); return CSSInjectionManager.injectAdoptedCSSText(cssText, styleLink); } static async injectAdoptedCSSText(cssText: string, styleName: string): Promise<CSSStyleSheet> { const sheet = new CSSStyleSheet(); await sheet.replace(cssText); // Cache the stylesheet for future use CSSInjectionManager.CACHED_STYLES.set(styleName, sheet); return sheet; } static async preloadCSSText(cssText: string, styleName: string): Promise<CSSStyleSheet> { const sheet = await CSSInjectionManager.injectAdoptedCSSText(cssText, styleName); const dbName = 'css-cache'; const storeName = 'styles'; const dbService = new IndexedDBServiceInstance(); const db = await dbService.openDB(dbName, storeName); await dbService.saveToDB(db, storeName, styleName, { css: cssText, timestamp: Date.now() }); return sheet; } private static async getCachedOrFetchCSS( component: ICSSInjectionComponent, styleLink: string, db: IDBDatabase, storeName: string, maxAgeDays: number ): Promise<string> { const entry = await component.indexedDBService.getFromDB(db, storeName, styleLink); let cssText: string | null = null; if (entry && typeof entry === 'object' && 'css' in entry && 'timestamp' in entry) { const expired = Date.now() - entry.timestamp > maxAgeDays; if (!expired) { cssText = entry.css; } } if (!cssText) { cssText = await fetch(styleLink).then(res => res.text()); await component.indexedDBService.saveToDB(db, storeName, styleLink, { css: cssText, timestamp: Date.now() }); console.log(`System saved stylesheet: ${styleLink} to IndexedDB`); } return cssText; } private static setStylesOwner(component: ICSSInjectionComponent): void { if (!CSSInjectionManager.STYLES_OWNER_COMPONENT) { CSSInjectionManager.STYLES_OWNER_COMPONENT = component; } } } export default CSSInjectionManager; export { CSSInjectMode, ICSSInjectionOptions, ICSSInjectionComponent };