diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index e739463d..36c3e923 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -2,9 +2,10 @@ import { StyleDependency } from '../../types' import { UniwindListener } from '../listener' class CSSListenerBuilder { + activeRules = new Set() private classNameMediaQueryListeners = new Map() private listeners = new Map>() - private registeredRules = new Map() + private registeredRulesMediaQueries = new Map() private processedStyleSheets = new WeakSet() private pendingInitialization: number | undefined = undefined @@ -15,6 +16,22 @@ class CSSListenerBuilder { const observer = new MutationObserver(mutations => { for (const mutation of mutations) { + if (mutation.type === 'attributes') { + const el = mutation.target as HTMLLinkElement | HTMLStyleElement + + if (!('sheet' in el)) { + continue + } + + const sheet = el.sheet + + if (sheet) { + this.processedStyleSheets.delete(sheet) + } + + this.scheduleInitialization() + } + if (mutation.type === 'childList') { this.scheduleInitialization() } @@ -83,8 +100,19 @@ class CSSListenerBuilder { } } + private pruneStaleRules() { + const activeSheets = new Set(Array.from(document.styleSheets)) + + for (const rule of this.activeRules) { + if (!rule.parentStyleSheet || !activeSheets.has(rule.parentStyleSheet)) { + this.activeRules.delete(rule) + } + } + } + private initialize() { this.pendingInitialization = undefined + this.pruneStaleRules() for (const sheet of Array.from(document.styleSheets)) { // Skip already processed stylesheets @@ -121,6 +149,10 @@ class CSSListenerBuilder { return rule.constructor.name === 'CSSMediaRule' } + private isSupportsRule(rule: CSSRule): rule is CSSSupportsRule { + return rule.constructor.name === 'CSSSupportsRule' + } + private collectParentMediaQueries(rule: CSSRule, acc = [] as Array): Array { const { parentRule } = rule @@ -144,13 +176,25 @@ class CSSListenerBuilder { if (this.isStyleRule(rule)) { const mediaQueries = this.collectParentMediaQueries(rule) + this.activeRules.add(rule) + if (mediaQueries.length > 0) { - this.addMediaQuery(mediaQueries, rule.selectorText) + this.addMediaQuery(mediaQueries, rule) } continue } + if (this.isSupportsRule(rule)) { + if (!CSS.supports(rule.conditionText)) { + continue + } + + this.addMediaQueriesDeep(rule.cssRules) + + continue + } + if ('cssRules' in rule && rule.cssRules instanceof CSSRuleList) { this.addMediaQueriesDeep(rule.cssRules) @@ -159,27 +203,50 @@ class CSSListenerBuilder { } } - private addMediaQuery(mediaQueries: Array, className: string) { + private addMediaQuery(mediaQueries: Array, rule: CSSStyleRule) { + const className = rule.selectorText const rules = mediaQueries.map(mediaQuery => mediaQuery.conditionText).sort().join(' and ') const parsedClassName = className.replace('.', '').replace('\\', '') - const cachedMediaQueryList = this.registeredRules.get(rules) + const cachedMediaQueryList = this.registeredRulesMediaQueries.get(rules) if (cachedMediaQueryList) { this.classNameMediaQueryListeners.set(parsedClassName, cachedMediaQueryList) + this.toggleRule(cachedMediaQueryList, rule) + + cachedMediaQueryList.addEventListener('change', () => { + this.toggleRule(cachedMediaQueryList, rule) + }) return } const mediaQueryList = window.matchMedia(rules) - this.registeredRules.set(rules, mediaQueryList) + this.toggleRule(mediaQueryList, rule) + this.registeredRulesMediaQueries.set(rules, mediaQueryList) this.listeners.set(mediaQueryList, new Set()) this.classNameMediaQueryListeners.set(parsedClassName, mediaQueryList) mediaQueryList.addEventListener('change', () => { - this.listeners.get(mediaQueryList)!.forEach(listener => listener()) + this.listeners.get(mediaQueryList)!.forEach(listener => { + listener() + }) + this.toggleRule(mediaQueryList, rule) }) } + + private isRuleLive(rule: CSSStyleRule) { + const sheet = rule.parentStyleSheet + return sheet !== null && Array.from(document.styleSheets).includes(sheet) + } + + private toggleRule(mqList: MediaQueryList, rule: CSSStyleRule) { + if (mqList.matches && this.isRuleLive(rule)) { + this.activeRules.add(rule) + } else { + this.activeRules.delete(rule) + } + } } export const CSSListener = new CSSListenerBuilder() diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index 1b84569d..e8f73449 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -1,4 +1,5 @@ import { RNStyle, UniwindContextType } from '../types' +import { CSSListener } from './cssListener' import { parseCSSValue } from './parseCSSValue' const dummyParent = typeof document !== 'undefined' @@ -15,40 +16,40 @@ if (dummyParent && dummy) { dummyParent.appendChild(dummy) } -const getComputedStyles = () => { +const getActiveStylesForClass = (className: string) => { + const extractedStyles = {} as Record + if (!dummy) { - return {} as CSSStyleDeclaration + return extractedStyles } + const classNames = className.split(/\s+/).filter(Boolean) const computedStyles = window.getComputedStyle(dummy) - const styles = {} as CSSStyleDeclaration - - // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let i = 0; i < computedStyles.length; i++) { - // Typescript is unable to infer it properly - const prop = computedStyles[i] as any - styles[prop] = computedStyles.getPropertyValue(prop) - } + CSSListener.activeRules.forEach(rule => { + const selector = rule.selectorText + const mightMatch = classNames.some((cls) => selector.includes(`.${CSS.escape(cls)}`)) - return styles -} - -const initialStyles = typeof document !== 'undefined' - ? getComputedStyles() - : {} as CSSStyleDeclaration - -const getObjectDifference = (obj1: T, obj2: T): T => { - const diff = {} as T - const keys = Object.keys(obj2) as Array + if (!mightMatch) { + return + } - keys.forEach(key => { - if (obj2[key] !== obj1[key]) { - diff[key] = obj2[key] + // element.matches() throws errors if it sees pseudo-elements like ::before + // So we strip them out safely just for the matching test + const safeSelector = selector.replace(/::[a-z-]+/gi, '') + + try { + if (safeSelector !== '' && dummy.matches(safeSelector)) { + for (const propertyName of rule.style) { + extractedStyles[propertyName] = computedStyles.getPropertyValue(propertyName) + } + } + } catch { + // Failsafe for unparseable selectors } }) - return diff + return extractedStyles } export const getWebStyles = (className: string | undefined, uniwindContext: UniwindContextType): RNStyle => { @@ -68,7 +69,7 @@ export const getWebStyles = (className: string | undefined, uniwindContext: Uniw dummy.className = className - const computedStyles = getObjectDifference(initialStyles, getComputedStyles()) + const computedStyles = getActiveStylesForClass(className) return Object.fromEntries( Object.entries(computedStyles) diff --git a/packages/uniwind/tests/e2e/getWebStyles.test.ts b/packages/uniwind/tests/e2e/getWebStyles.test.ts index b71366ce..306d84f2 100644 --- a/packages/uniwind/tests/e2e/getWebStyles.test.ts +++ b/packages/uniwind/tests/e2e/getWebStyles.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'fs' import type { UniwindContextType } from '../../src/core/types' import { BUNDLE_PATH, CSS_PATH } from './global-setup' import './window.d.ts' +import { TW_BLUE_500, TW_RED_500 } from '../consts' // Load the compiled artifacts produced by global-setup.ts const compiledCSS = readFileSync(CSS_PATH, 'utf-8') @@ -37,10 +38,16 @@ test.beforeEach(async ({ page }) => { await page.addScriptTag({ content: bundle }) }) -test.describe('getWebStyles — background color', () => { +test.describe('getWebStyles — basic cases', () => { test('bg-red-500 → backgroundColor #fb2c36', async ({ page }) => { const styles = await getWebStyles(page, 'bg-red-500') - expect(styles.backgroundColor).toBe('#fb2c36') + expect(styles.backgroundColor).toBe(TW_RED_500) + }) + + test('bg-red-500 color-blue-500 → backgroundColor tw-red-500 & color tw-blue-500', async ({ page }) => { + const styles = await getWebStyles(page, 'bg-red-500 text-blue-500') + expect(styles.backgroundColor).toBe(TW_RED_500) + expect(styles.color).toBe(TW_BLUE_500) }) }) @@ -57,9 +64,21 @@ test.describe('getWebStyles — scoped theme', () => { }) test.describe('getWebStyles - html default styles', () => { - // TODO fix html default styles in getWebStyles - test.skip('text-base -> fontSize 16px', async ({ page }) => { + test('bg-red-500 -> should only include backgroundColor', async ({ page }) => { + const styles = await getWebStyles(page, 'bg-red-500') + expect(styles).toEqual({ + backgroundColor: TW_RED_500, + }) + }) + + test('text-base -> fontSize 16px', async ({ page }) => { const styles = await getWebStyles(page, 'text-base') expect(styles.fontSize).toBe('16px') + expect(styles.lineHeight).toBe('24px') + }) + + test('max-w-0:text-base -> empty object', async ({ page }) => { + const styles = await getWebStyles(page, 'max-w-0:text-base') + expect(styles).toEqual({}) }) })