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
79 changes: 73 additions & 6 deletions packages/uniwind/src/core/web/cssListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { StyleDependency } from '../../types'
import { UniwindListener } from '../listener'

class CSSListenerBuilder {
activeRules = new Set<CSSStyleRule>()
private classNameMediaQueryListeners = new Map<string, MediaQueryList>()
private listeners = new Map<MediaQueryList, Set<VoidFunction>>()
private registeredRules = new Map<string, MediaQueryList>()
private registeredRulesMediaQueries = new Map<string, MediaQueryList>()
private processedStyleSheets = new WeakSet<CSSStyleSheet>()
private pendingInitialization: number | undefined = undefined

Expand All @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<CSSMediaRule>): Array<CSSMediaRule> {
const { parentRule } = rule

Expand All @@ -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)

Expand All @@ -159,27 +203,50 @@ class CSSListenerBuilder {
}
}

private addMediaQuery(mediaQueries: Array<CSSMediaRule>, className: string) {
private addMediaQuery(mediaQueries: Array<CSSMediaRule>, 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()
51 changes: 26 additions & 25 deletions packages/uniwind/src/core/web/getWebStyles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { RNStyle, UniwindContextType } from '../types'
import { CSSListener } from './cssListener'
import { parseCSSValue } from './parseCSSValue'

const dummyParent = typeof document !== 'undefined'
Expand All @@ -15,40 +16,40 @@ if (dummyParent && dummy) {
dummyParent.appendChild(dummy)
}

const getComputedStyles = () => {
const getActiveStylesForClass = (className: string) => {
const extractedStyles = {} as Record<string, string>

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 = <T extends object>(obj1: T, obj2: T): T => {
const diff = {} as T
const keys = Object.keys(obj2) as Array<keyof T>
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 => {
Expand All @@ -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)
Expand Down
27 changes: 23 additions & 4 deletions packages/uniwind/tests/e2e/getWebStyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
})
})

Expand All @@ -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({})
})
})