From 54bf174152176f7dd1207390d5aeb3f5a69a3bcc Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Fri, 6 Mar 2026 09:03:50 +0100 Subject: [PATCH 1/7] fix: getWebStyles not return default html styles --- packages/uniwind/src/core/web/cssListener.ts | 9 ++-- packages/uniwind/src/core/web/getWebStyles.ts | 54 ++++++++++--------- .../uniwind/tests/e2e/getWebStyles.test.ts | 21 ++++++-- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index e739463d..d095b759 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 { + registeredRules = 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 @@ -142,6 +143,8 @@ class CSSListenerBuilder { private addMediaQueriesDeep(rules: CSSRuleList) { for (const rule of Array.from(rules)) { if (this.isStyleRule(rule)) { + this.registeredRules.add(rule) + const mediaQueries = this.collectParentMediaQueries(rule) if (mediaQueries.length > 0) { @@ -162,7 +165,7 @@ class CSSListenerBuilder { private addMediaQuery(mediaQueries: Array, className: string) { 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) @@ -172,7 +175,7 @@ class CSSListenerBuilder { const mediaQueryList = window.matchMedia(rules) - this.registeredRules.set(rules, mediaQueryList) + this.registeredRulesMediaQueries.set(rules, mediaQueryList) this.listeners.set(mediaQueryList, new Set()) this.classNameMediaQueryListeners.set(parsedClassName, mediaQueryList) diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index 1b84569d..8e5bd3ac 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,43 @@ 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.registeredRules.forEach(rule => { + const selector = rule.selectorText + const mightMatch = classNames.some((cls) => selector.includes(`.${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) { + const propertyValue = computedStyles.getPropertyValue(propertyName) + const camelCaseName = propertyName.replace(/-([a-z])/g, (g) => (g[1] ?? '').toUpperCase()) + + extractedStyles[camelCaseName] = propertyValue + } + } + } catch { + // Failsafe for unparseable selectors } }) - return diff + return extractedStyles } export const getWebStyles = (className: string | undefined, uniwindContext: UniwindContextType): RNStyle => { @@ -68,7 +72,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..99652aa6 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,8 +64,14 @@ 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') }) From 8cd77d098f8437d495eb8d8c492b8ddccc3317b4 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Fri, 6 Mar 2026 09:05:40 +0100 Subject: [PATCH 2/7] chore: update extractedStyles type --- packages/uniwind/src/core/web/getWebStyles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index 8e5bd3ac..c21ea550 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -17,7 +17,7 @@ if (dummyParent && dummy) { } const getActiveStylesForClass = (className: string) => { - const extractedStyles = {} as Record + const extractedStyles = {} as CSSStyleDeclaration if (!dummy) { return extractedStyles From 90fa00bd1d9bb6bf8c6faedbbcb2fef75a740e11 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Fri, 6 Mar 2026 09:17:24 +0100 Subject: [PATCH 3/7] chore: revert --- packages/uniwind/src/core/web/getWebStyles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index c21ea550..8e5bd3ac 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -17,7 +17,7 @@ if (dummyParent && dummy) { } const getActiveStylesForClass = (className: string) => { - const extractedStyles = {} as CSSStyleDeclaration + const extractedStyles = {} as Record if (!dummy) { return extractedStyles From 80180c0a636cbc3017152cd98ac36841804ff788 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Fri, 6 Mar 2026 10:48:24 +0100 Subject: [PATCH 4/7] chore: code review improvements --- packages/uniwind/src/core/web/cssListener.ts | 34 ++++++++++++++++--- packages/uniwind/src/core/web/getWebStyles.ts | 9 ++--- .../uniwind/tests/e2e/getWebStyles.test.ts | 6 ++++ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index d095b759..d20419d8 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -16,7 +16,7 @@ class CSSListenerBuilder { const observer = new MutationObserver(mutations => { for (const mutation of mutations) { - if (mutation.type === 'childList') { + if (mutation.type === 'childList' || mutation.type === 'attributes') { this.scheduleInitialization() } } @@ -84,8 +84,19 @@ class CSSListenerBuilder { } } + private pruneStaleRules() { + const activeSheets = new Set(Array.from(document.styleSheets)) + + for (const rule of this.registeredRules) { + if (!rule.parentStyleSheet || !activeSheets.has(rule.parentStyleSheet)) { + this.registeredRules.delete(rule) + } + } + } + private initialize() { this.pendingInitialization = undefined + this.pruneStaleRules() for (const sheet of Array.from(document.styleSheets)) { // Skip already processed stylesheets @@ -143,12 +154,12 @@ class CSSListenerBuilder { private addMediaQueriesDeep(rules: CSSRuleList) { for (const rule of Array.from(rules)) { if (this.isStyleRule(rule)) { - this.registeredRules.add(rule) - const mediaQueries = this.collectParentMediaQueries(rule) + this.registeredRules.add(rule) + if (mediaQueries.length > 0) { - this.addMediaQuery(mediaQueries, rule.selectorText) + this.addMediaQuery(mediaQueries, rule) } continue @@ -162,7 +173,8 @@ 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.registeredRulesMediaQueries.get(rules) @@ -175,12 +187,24 @@ class CSSListenerBuilder { const mediaQueryList = window.matchMedia(rules) + if (mediaQueryList.matches) { + this.registeredRules.add(rule) + } else { + this.registeredRules.delete(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()) + + if (mediaQueryList.matches) { + this.registeredRules.add(rule) + } else { + this.registeredRules.delete(rule) + } }) } } diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index 8e5bd3ac..dc0486e0 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -17,7 +17,7 @@ if (dummyParent && dummy) { } const getActiveStylesForClass = (className: string) => { - const extractedStyles = {} as Record + const extractedStyles = {} as Record if (!dummy) { return extractedStyles @@ -28,7 +28,7 @@ const getActiveStylesForClass = (className: string) => { CSSListener.registeredRules.forEach(rule => { const selector = rule.selectorText - const mightMatch = classNames.some((cls) => selector.includes(`.${cls}`)) + const mightMatch = classNames.some((cls) => selector.includes(`.${CSS.escape(cls)}`)) if (!mightMatch) { return @@ -41,10 +41,7 @@ const getActiveStylesForClass = (className: string) => { try { if (safeSelector !== '' && dummy.matches(safeSelector)) { for (const propertyName of rule.style) { - const propertyValue = computedStyles.getPropertyValue(propertyName) - const camelCaseName = propertyName.replace(/-([a-z])/g, (g) => (g[1] ?? '').toUpperCase()) - - extractedStyles[camelCaseName] = propertyValue + extractedStyles[propertyName] = computedStyles.getPropertyValue(propertyName) } } } catch { diff --git a/packages/uniwind/tests/e2e/getWebStyles.test.ts b/packages/uniwind/tests/e2e/getWebStyles.test.ts index 99652aa6..306d84f2 100644 --- a/packages/uniwind/tests/e2e/getWebStyles.test.ts +++ b/packages/uniwind/tests/e2e/getWebStyles.test.ts @@ -74,5 +74,11 @@ test.describe('getWebStyles - html default styles', () => { 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({}) }) }) From 40f1f7fe41fef3b945cde4603a7c4dd9034015c3 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Fri, 6 Mar 2026 10:59:00 +0100 Subject: [PATCH 5/7] chore: more corrections --- packages/uniwind/src/core/web/cssListener.ts | 47 ++++++++++++------- packages/uniwind/src/core/web/getWebStyles.ts | 2 +- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index d20419d8..59b1c2bc 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -2,7 +2,7 @@ import { StyleDependency } from '../../types' import { UniwindListener } from '../listener' class CSSListenerBuilder { - registeredRules = new Set() + activeRules = new Set() private classNameMediaQueryListeners = new Map() private listeners = new Map>() private registeredRulesMediaQueries = new Map() @@ -87,9 +87,9 @@ class CSSListenerBuilder { private pruneStaleRules() { const activeSheets = new Set(Array.from(document.styleSheets)) - for (const rule of this.registeredRules) { + for (const rule of this.activeRules) { if (!rule.parentStyleSheet || !activeSheets.has(rule.parentStyleSheet)) { - this.registeredRules.delete(rule) + this.activeRules.delete(rule) } } } @@ -133,6 +133,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 @@ -156,7 +160,7 @@ class CSSListenerBuilder { if (this.isStyleRule(rule)) { const mediaQueries = this.collectParentMediaQueries(rule) - this.registeredRules.add(rule) + this.activeRules.add(rule) if (mediaQueries.length > 0) { this.addMediaQuery(mediaQueries, rule) @@ -165,6 +169,14 @@ class CSSListenerBuilder { continue } + if (this.isSupportsRule(rule)) { + if (!CSS.supports(rule.conditionText)) { + continue + } + + this.addMediaQueriesDeep(rule.cssRules) + } + if ('cssRules' in rule && rule.cssRules instanceof CSSRuleList) { this.addMediaQueriesDeep(rule.cssRules) @@ -181,32 +193,35 @@ class CSSListenerBuilder { if (cachedMediaQueryList) { this.classNameMediaQueryListeners.set(parsedClassName, cachedMediaQueryList) + this.toggleRule(cachedMediaQueryList, rule) + + cachedMediaQueryList.addEventListener('change', () => { + this.toggleRule(cachedMediaQueryList, rule) + }) return } const mediaQueryList = window.matchMedia(rules) - if (mediaQueryList.matches) { - this.registeredRules.add(rule) - } else { - this.registeredRules.delete(rule) - } - + 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()) - - if (mediaQueryList.matches) { - this.registeredRules.add(rule) - } else { - this.registeredRules.delete(rule) - } + this.toggleRule(mediaQueryList, rule) }) } + + private toggleRule(mqList: MediaQueryList, rule: CSSStyleRule) { + if (mqList.matches) { + 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 dc0486e0..e8f73449 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -26,7 +26,7 @@ const getActiveStylesForClass = (className: string) => { const classNames = className.split(/\s+/).filter(Boolean) const computedStyles = window.getComputedStyle(dummy) - CSSListener.registeredRules.forEach(rule => { + CSSListener.activeRules.forEach(rule => { const selector = rule.selectorText const mightMatch = classNames.some((cls) => selector.includes(`.${CSS.escape(cls)}`)) From fd94d2d1de2cd9b034a55e7240767973e6a6cf13 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Fri, 6 Mar 2026 11:07:38 +0100 Subject: [PATCH 6/7] chore: more corrections --- packages/uniwind/src/core/web/cssListener.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index 59b1c2bc..b9242674 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -175,6 +175,8 @@ class CSSListenerBuilder { } this.addMediaQueriesDeep(rule.cssRules) + + continue } if ('cssRules' in rule && rule.cssRules instanceof CSSRuleList) { @@ -210,7 +212,9 @@ class CSSListenerBuilder { 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) }) } From 4cc95706f7ab80ec81535445cb31007d6a3612e2 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Fri, 6 Mar 2026 11:18:14 +0100 Subject: [PATCH 7/7] chore: more corrections --- packages/uniwind/src/core/web/cssListener.ts | 25 ++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index b9242674..36c3e923 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -16,7 +16,23 @@ class CSSListenerBuilder { const observer = new MutationObserver(mutations => { for (const mutation of mutations) { - if (mutation.type === 'childList' || mutation.type === 'attributes') { + 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() } } @@ -219,8 +235,13 @@ class CSSListenerBuilder { }) } + 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) { + if (mqList.matches && this.isRuleLive(rule)) { this.activeRules.add(rule) } else { this.activeRules.delete(rule)