diff --git a/CONTEXT.md b/CONTEXT.md index 24ff1a2d..fcde04e2 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`. 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/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index d5801c87..934faefd 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -1,8 +1,11 @@ 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>() private classNameMediaQueryListeners = new Map() private listeners = new Map>() private registeredRulesMediaQueries = new Map() @@ -47,6 +50,28 @@ class CSSListenerBuilder { }) } + getRulesForClassName(className: string) { + const cached = this.classNameRules.get(className) + + if (cached) { + this.classNameRules.delete(className) + this.classNameRules.set(className, 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))) + + if (this.classNameRules.size >= MAX_CLASS_NAME_CACHE_SIZE) { + this.classNameRules.delete(this.classNameRules.keys().next().value!) + } + + this.classNameRules.set(className, rules) + + return rules + } + getSnapshot(classNames: string) { const mediaQueries = new Set( classNames @@ -122,6 +147,7 @@ class CSSListenerBuilder { } private initialize() { + this.classNameRules.clear() this.pendingInitialization = undefined this.pruneStaleRules() @@ -252,6 +278,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 c29bf464..47edd3a0 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.prototype.hasOwnProperty.call(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,16 +52,10 @@ const getActiveStylesForClass = (className: string) => { return extractedStyles } - const classNames = className.split(/\s+/).filter(Boolean) 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(`.${CSS.escape(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 @@ -96,13 +100,12 @@ export const getWebStyles = ( dummyParent?.removeAttribute('dir') } - const disposeScopedVariables = applyScopedVariables(uniwindContext) + applyScopedVariables(uniwindContext) + dummy.className = className - try { - 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 +118,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 +132,11 @@ export const getWebStyles = ( }), ) } finally { - disposeScopedVariables() + if (dataSet) { + Object.keys(dataSet).forEach(key => { + delete dummy.dataset[key] + }) + } } } @@ -156,13 +157,8 @@ export const getWebVariable = (name: string, uniwindContext: UniwindContextType) dummyParent.removeAttribute('dir') } - const disposeScopedVariables = applyScopedVariables(uniwindContext) + applyScopedVariables(uniwindContext) + const variable = window.getComputedStyle(dummyParent).getPropertyValue(name) - try { - const variable = window.getComputedStyle(dummyParent).getPropertyValue(name) - - return parseCSSValue(variable) - } finally { - disposeScopedVariables() - } + return parseCSSValue(variable) }