From 0256fbb9cea3da469bbffc14a5c9a8131fd2b2ca Mon Sep 17 00:00:00 2001 From: eserdeiro Date: Thu, 24 Sep 2026 10:01:42 -0300 Subject: [PATCH 1/6] fix(web): avoid repeated scoped variable mutations --- packages/uniwind/src/core/web/getWebStyles.ts | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index c29bf464..28482ca9 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -1,5 +1,5 @@ import { generateDataSet } from '../../components/web/generateDataSet' -import type { RNStyle, UniwindContextType } from '../types' +import type { CSSVariables, RNStyle, UniwindContextType } from '../types' import { CSSListener } from './cssListener' import { parseCSSValue, toWebValue } from './webUtils' @@ -17,22 +17,32 @@ if (dummyParent && dummy) { dummyParent.appendChild(dummy) } -// Applies scoped variables to dummyParent so they cascade to dummy during style -// computation. Returns a disposer that removes them +// Keep the private probe in the current scope; only changed variables invalidate its styles. const applyScopedVariables = (uniwindContext: UniwindContextType) => { - if (!dummyParent || uniwindContext.variables === null) { - return () => {} + if (!dummyParent) { + return } - const names = Object.keys(uniwindContext.variables) + const variables: CSSVariables = uniwindContext.variables ?? {} + const style = dummyParent.style - Object.entries(uniwindContext.variables).forEach(([name, value]) => { - dummyParent.style.setProperty(name, toWebValue(value)) + Array.from(style).forEach(name => { + if (name.startsWith('--') && !Object.hasOwn(variables, name)) { + style.removeProperty(name) + } }) - return () => { - names.forEach(name => dummyParent.style.removeProperty(name)) - } + Object.entries(variables).forEach(([name, value]) => { + if (!name.startsWith('--')) { + return + } + + const next = toWebValue(value) + + if (style.getPropertyValue(name) !== next) { + style.setProperty(name, next) + } + }) } const getActiveStylesForClass = (className: string) => { @@ -42,12 +52,12 @@ const getActiveStylesForClass = (className: string) => { return extractedStyles } - const classNames = className.split(/\s+/).filter(Boolean) + const classNames = className.split(/\s+/).filter(Boolean).map((cls) => `.${CSS.escape(cls)}`) const computedStyles = window.getComputedStyle(dummy) CSSListener.activeRules.forEach(rule => { const selector = rule.selectorText - const mightMatch = classNames.some((cls) => selector.includes(`.${CSS.escape(cls)}`)) + const mightMatch = classNames.some((cls) => selector.includes(cls)) if (!mightMatch) { return @@ -96,13 +106,12 @@ export const getWebStyles = ( dummyParent?.removeAttribute('dir') } - const disposeScopedVariables = applyScopedVariables(uniwindContext) - - try { - dummy.className = className + applyScopedVariables(uniwindContext) + dummy.className = className - const dataSet = generateDataSet(componentProps ?? {}) + const dataSet = generateDataSet(componentProps ?? {}) + try { if (dataSet) { Object.entries(dataSet).forEach(([key, value]) => { if (value === false || value === undefined) { @@ -115,12 +124,6 @@ export const getWebStyles = ( const computedStyles = getActiveStylesForClass(className) - if (dataSet) { - Object.keys(dataSet).forEach(key => { - delete dummy.dataset[key] - }) - } - return Object.fromEntries( Object.entries(computedStyles) .map(([key, value]) => { @@ -135,7 +138,11 @@ export const getWebStyles = ( }), ) } finally { - disposeScopedVariables() + if (dataSet) { + Object.keys(dataSet).forEach(key => { + delete dummy.dataset[key] + }) + } } } @@ -156,13 +163,8 @@ export const getWebVariable = (name: string, uniwindContext: UniwindContextType) dummyParent.removeAttribute('dir') } - const disposeScopedVariables = applyScopedVariables(uniwindContext) - - try { - const variable = window.getComputedStyle(dummyParent).getPropertyValue(name) + applyScopedVariables(uniwindContext) + const variable = window.getComputedStyle(dummyParent).getPropertyValue(name) - return parseCSSValue(variable) - } finally { - disposeScopedVariables() - } + return parseCSSValue(variable) } From 5f251fcfb44388426d6859c0942610de4fe6b8bd Mon Sep 17 00:00:00 2001 From: Emanuel Serdeiro <50755035+eserdeiro@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:18:27 -0300 Subject: [PATCH 2/6] Update packages/uniwind/src/core/web/getWebStyles.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- 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 28482ca9..f1c3b246 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -27,7 +27,7 @@ const applyScopedVariables = (uniwindContext: UniwindContextType) => { const style = dummyParent.style Array.from(style).forEach(name => { - if (name.startsWith('--') && !Object.hasOwn(variables, name)) { + if (name.startsWith('--') && !Object.prototype.hasOwnProperty.call(variables, name)) { style.removeProperty(name) } }) From ccb58d2c5684deb16dc77f4347a3aab12844aed3 Mon Sep 17 00:00:00 2001 From: eserdeiro Date: Thu, 24 Sep 2026 16:16:41 -0300 Subject: [PATCH 3/6] perf(web): cache CSS rule candidates and skip unchanged scoped variables --- CONTEXT.md | 4 ++-- packages/uniwind/src/core/web/cssListener.ts | 18 ++++++++++++++++++ packages/uniwind/src/core/web/getWebStyles.ts | 12 ++++-------- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 24ff1a2d..b001b4b7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -79,10 +79,10 @@ Web runtime: - Web keeps styles in CSS and passes `{ $$css: true, tailwind: className }` through RNW style arrays. - `getWebStyles` uses a hidden DOM element to compute style values when a JS value is needed, such as color extraction or `useResolveClassNames`. -- `CSSListener` tracks active CSS rules and media queries, then notifies subscribers when class-dependent media rules change. +- `CSSListener` tracks active CSS rules and media queries, then notifies subscribers when class-dependent media rules change. Candidate rules are cached by class string and invalidated when stylesheets are processed or media rules are toggled; computed values and selector matching remain live. - `ScopedTheme` renders a `div` with the theme class and `display: contents` on web. - `LayoutDirection` renders a contents-style wrapper with `direction`/`dir` semantics so RTL/LTR variants can be scoped to a subtree. -- `ScopedVariables` renders a `display: contents` wrapper and sets its variables as inline custom properties on that wrapper, so the real DOM cascade resolves `var(--name)` to the scoped value for every descendant (numbers become px). During JS reads (`getWebVariable` / `useResolveClassNames`) it also applies the variables to the hidden `dummyParent`, then clears them. +- `ScopedVariables` renders a `display: contents` wrapper and sets its variables as inline custom properties on that wrapper, so the real DOM cascade resolves `var(--name)` to the scoped value for every descendant (numbers become px). During JS reads (`getWebVariable` / `useResolveClassNames`) it also applies the variables to the private hidden `dummyParent`. Repeated reads with the same variables object skip reapplication; switching scopes removes stale properties and applies changed values. - Dynamic CSS variable updates are written into a generated `#uniwind-dynamic-styles` style element. Shared runtime: diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index d5801c87..06d1c9a5 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -3,6 +3,7 @@ import { UniwindListener } from '../listener' class CSSListenerBuilder { activeRules = new Set() + private classNameRules = new Map>() private classNameMediaQueryListeners = new Map() private listeners = new Map>() private registeredRulesMediaQueries = new Map() @@ -47,6 +48,21 @@ class CSSListenerBuilder { }) } + getRulesForClassName(className: string) { + const cached = this.classNameRules.get(className) + + if (cached) { + return cached + } + + const selectors = className.split(/\s+/).filter(Boolean).map(cls => `.${CSS.escape(cls)}`) + const rules = Array.from(this.activeRules).filter(rule => selectors.some(cls => rule.selectorText.includes(cls))) + + this.classNameRules.set(className, rules) + + return rules + } + getSnapshot(classNames: string) { const mediaQueries = new Set( classNames @@ -122,6 +138,7 @@ class CSSListenerBuilder { } private initialize() { + this.classNameRules.clear() this.pendingInitialization = undefined this.pruneStaleRules() @@ -252,6 +269,7 @@ class CSSListenerBuilder { } private toggleRule(mqList: MediaQueryList, rule: CSSStyleRule) { + this.classNameRules.clear() if (mqList.matches && this.isRuleLive(rule)) { this.activeRules.add(rule) } else { diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index f1c3b246..e07d501c 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -18,8 +18,9 @@ if (dummyParent && dummy) { } // Keep the private probe in the current scope; only changed variables invalidate its styles. +let appliedVariables: UniwindContextType['variables'] | undefined const applyScopedVariables = (uniwindContext: UniwindContextType) => { - if (!dummyParent) { + if (!dummyParent || appliedVariables === uniwindContext.variables) { return } @@ -43,6 +44,7 @@ const applyScopedVariables = (uniwindContext: UniwindContextType) => { style.setProperty(name, next) } }) + appliedVariables = uniwindContext.variables } const getActiveStylesForClass = (className: string) => { @@ -52,16 +54,10 @@ const getActiveStylesForClass = (className: string) => { return extractedStyles } - const classNames = className.split(/\s+/).filter(Boolean).map((cls) => `.${CSS.escape(cls)}`) const computedStyles = window.getComputedStyle(dummy) - CSSListener.activeRules.forEach(rule => { + CSSListener.getRulesForClassName(className).forEach(rule => { const selector = rule.selectorText - const mightMatch = classNames.some((cls) => selector.includes(cls)) - - if (!mightMatch) { - return - } // element.matches() throws errors if it sees pseudo-elements like ::before // So we strip them out safely just for the matching test From 79c3ab798c024d6b10f75c0ce66409d9f68a118a Mon Sep 17 00:00:00 2001 From: eserdeiro Date: Thu, 24 Sep 2026 16:25:23 -0300 Subject: [PATCH 4/6] feat(web): implement class name cache size limit in CSSListenerBuilder --- packages/uniwind/src/core/web/cssListener.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index 06d1c9a5..747bdf00 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -1,6 +1,8 @@ import { StyleDependency } from '../../common/consts' import { UniwindListener } from '../listener' +const MAX_CLASS_NAME_CACHE_SIZE = 500 + class CSSListenerBuilder { activeRules = new Set() private classNameRules = new Map>() @@ -58,6 +60,10 @@ class CSSListenerBuilder { const selectors = className.split(/\s+/).filter(Boolean).map(cls => `.${CSS.escape(cls)}`) const rules = Array.from(this.activeRules).filter(rule => selectors.some(cls => rule.selectorText.includes(cls))) + if (this.classNameRules.size >= MAX_CLASS_NAME_CACHE_SIZE) { + this.classNameRules.delete(this.classNameRules.keys().next().value!) + } + this.classNameRules.set(className, rules) return rules From 7a66bf03b170de98a8d9b69f7af76c8fd7158712 Mon Sep 17 00:00:00 2001 From: eserdeiro Date: Thu, 24 Sep 2026 16:31:54 -0300 Subject: [PATCH 5/6] fix(web): refresh cached class names in CSSListenerBuilder --- packages/uniwind/src/core/web/cssListener.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index 747bdf00..934faefd 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -54,6 +54,9 @@ class CSSListenerBuilder { const cached = this.classNameRules.get(className) if (cached) { + this.classNameRules.delete(className) + this.classNameRules.set(className, cached) + return cached } From 8b324ff6385bf2647e82169ea1226c67ee82c7d6 Mon Sep 17 00:00:00 2001 From: eserdeiro Date: Fri, 25 Sep 2026 10:06:06 -0300 Subject: [PATCH 6/6] refactor(web): optimize scoped variable application logic in getWebStyles.ts --- CONTEXT.md | 2 +- packages/uniwind/src/core/web/getWebStyles.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index b001b4b7..fcde04e2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -82,7 +82,7 @@ Web runtime: - `CSSListener` tracks active CSS rules and media queries, then notifies subscribers when class-dependent media rules change. Candidate rules are cached by class string and invalidated when stylesheets are processed or media rules are toggled; computed values and selector matching remain live. - `ScopedTheme` renders a `div` with the theme class and `display: contents` on web. - `LayoutDirection` renders a contents-style wrapper with `direction`/`dir` semantics so RTL/LTR variants can be scoped to a subtree. -- `ScopedVariables` renders a `display: contents` wrapper and sets its variables as inline custom properties on that wrapper, so the real DOM cascade resolves `var(--name)` to the scoped value for every descendant (numbers become px). During JS reads (`getWebVariable` / `useResolveClassNames`) it also applies the variables to the private hidden `dummyParent`. Repeated reads with the same variables object skip reapplication; switching scopes removes stale properties and applies changed values. +- `ScopedVariables` renders a `display: contents` wrapper and sets its variables as inline custom properties on that wrapper, so the real DOM cascade resolves `var(--name)` to the scoped value for every descendant (numbers become px). During JS reads (`getWebVariable` / `useResolveClassNames`) it also applies the variables to the private hidden `dummyParent`. Each read compares variable values against the applied inline properties, including changes made in place to the same variables object. Unchanged values avoid writes; switching scopes removes stale properties and applies changed values. - Dynamic CSS variable updates are written into a generated `#uniwind-dynamic-styles` style element. Shared runtime: diff --git a/packages/uniwind/src/core/web/getWebStyles.ts b/packages/uniwind/src/core/web/getWebStyles.ts index e07d501c..47edd3a0 100644 --- a/packages/uniwind/src/core/web/getWebStyles.ts +++ b/packages/uniwind/src/core/web/getWebStyles.ts @@ -18,9 +18,8 @@ if (dummyParent && dummy) { } // Keep the private probe in the current scope; only changed variables invalidate its styles. -let appliedVariables: UniwindContextType['variables'] | undefined const applyScopedVariables = (uniwindContext: UniwindContextType) => { - if (!dummyParent || appliedVariables === uniwindContext.variables) { + if (!dummyParent) { return } @@ -44,7 +43,6 @@ const applyScopedVariables = (uniwindContext: UniwindContextType) => { style.setProperty(name, next) } }) - appliedVariables = uniwindContext.variables } const getActiveStylesForClass = (className: string) => {