diff --git a/package.json b/package.json index 064df3c6..de1fd22b 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,13 @@ "module": "./plugin/index.js", "default": "./plugin/index.js" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./server": { + "types": "./lib/typescript/src/server/index.d.ts", + "module": "./lib/module/server/index", + "default": "./lib/commonjs/server/index", + "react-native": "./src/server" + } }, "files": [ "src", @@ -88,6 +94,7 @@ "husky": "9.1.7", "jest": "29.7.0", "metro-react-native-babel-preset": "0.77.0", + "next": "15.1.1", "nitro-codegen": "0.18.2", "react": "18.3.1", "react-native": "0.76.3", diff --git a/server/package.json b/server/package.json new file mode 100644 index 00000000..092e26cb --- /dev/null +++ b/server/package.json @@ -0,0 +1,6 @@ +{ + "main": "../lib/commonjs/server/index", + "module": "../lib/module/server/index", + "react-native": "../src/server/index", + "types": "../lib/typescript/src/server/index" +} diff --git a/src/components/ScopedTheme.tsx b/src/components/ScopedTheme.tsx index 31e027d1..b2ad83d2 100644 --- a/src/components/ScopedTheme.tsx +++ b/src/components/ScopedTheme.tsx @@ -21,7 +21,7 @@ export const ScopedTheme: React.FunctionComponent, children, - + ] return ( diff --git a/src/components/native/ImageBackground.native.tsx b/src/components/native/ImageBackground.native.tsx new file mode 100644 index 00000000..93f6e6fe --- /dev/null +++ b/src/components/native/ImageBackground.native.tsx @@ -0,0 +1,4 @@ +import { ImageBackground as NativeImageBackground } from 'react-native' +import { createUnistylesImageBackground } from '../../core' + +export const ImageBackground = createUnistylesImageBackground(NativeImageBackground) diff --git a/src/components/native/ImageBackground.tsx b/src/components/native/ImageBackground.tsx index 93f6e6fe..45228d3c 100644 --- a/src/components/native/ImageBackground.tsx +++ b/src/components/native/ImageBackground.tsx @@ -1,4 +1,56 @@ +import React from 'react' import { ImageBackground as NativeImageBackground } from 'react-native' -import { createUnistylesImageBackground } from '../../core' +import { forwardRef } from 'react' +import type { UnistylesValues } from '../../types' +import { getClassName } from '../../core' +import { isServer } from '../../web/utils' +import { UnistylesShadowRegistry } from '../../web' -export const ImageBackground = createUnistylesImageBackground(NativeImageBackground) +type Props = { + style?: UnistylesValues + imageStyle?: UnistylesValues +} + +export const ImageBackground = forwardRef((props, forwardedRef) => { + let storedRef: NativeImageBackground | null = null + let storedImageRef: NativeImageBackground | null = null + const styleClassNames = getClassName(props.style) + const imageClassNames = getClassName(props.imageStyle) + + return ( + // @ts-expect-error - RN types are not compatible with RNW styles + { + if (!ref) { + // @ts-expect-error hidden from TS + UnistylesShadowRegistry.remove(storedRef, styleClassNames?.hash) + } + + storedRef = ref + // @ts-expect-error hidden from TS + UnistylesShadowRegistry.add(ref, styleClassNames?.hash) + + if (typeof forwardedRef === 'function') { + return forwardedRef(ref) + } + + if (forwardedRef) { + forwardedRef.current = ref + } + }} + imageRef={isServer() ? undefined : ref => { + if (!ref) { + // @ts-expect-error hidden from TS + UnistylesShadowRegistry.remove(storedImageRef, imageClassNames?.hash) + } + + storedImageRef = ref + // @ts-expect-error hidden from TS + UnistylesShadowRegistry.add(ref, imageClassNames?.hash) + }} + /> + ) +}) diff --git a/src/components/native/Pressable.native.tsx b/src/components/native/Pressable.native.tsx index 273a8909..451eb952 100644 --- a/src/components/native/Pressable.native.tsx +++ b/src/components/native/Pressable.native.tsx @@ -22,9 +22,8 @@ export const Pressable = forwardRef(({ variants, style, .. ? unistyles : [unistyles] - // @ts-expect-error - this is hidden from TS + // @ts-expect-error web types are not compatible with RN styles UnistylesShadowRegistry.add(ref, styles) - storedRef.current = ref return passForwardedRef(props, ref, forwardedRef) @@ -45,7 +44,7 @@ export const Pressable = forwardRef(({ variants, style, .. UnistylesShadowRegistry.selectVariants(variants) } - // @ts-expect-error - this is hidden from TS + // @ts-expect-error hidden from TS UnistylesShadowRegistry.remove(storedRef.current) // @ts-expect-error - this is hidden from TS UnistylesShadowRegistry.add(storedRef.current, styles) diff --git a/src/components/native/Pressable.tsx b/src/components/native/Pressable.tsx index 67bd3958..088651df 100644 --- a/src/components/native/Pressable.tsx +++ b/src/components/native/Pressable.tsx @@ -1,7 +1,10 @@ -import React, { forwardRef, useRef } from 'react' +import React, { forwardRef } from 'react' import { Pressable as NativePressableReactNative } from 'react-native' -import type { PressableProps as Props, View, ViewStyle } from 'react-native' +import type { PressableProps as Props, View } from 'react-native' import { UnistylesShadowRegistry } from '../../specs' +import type { UnistylesValues } from '../../types' +import { getClassName } from '../../core' +import { isServer } from '../../web/utils' type Variants = Record type WebPressableState = { @@ -10,88 +13,55 @@ type WebPressableState = { focused: boolean } -type WebPressableStyle = ((state: WebPressableState) => ViewStyle) | ViewStyle +type WebPressableStyle = ((state: WebPressableState) => UnistylesValues) | UnistylesValues type PressableProps = Props & { variants?: Variants style?: WebPressableStyle, } -const initialState: WebPressableState = { - pressed: false, - hovered: false, - focused: false -} - -type UpdateStylesProps = { - ref: View | null, - style: WebPressableStyle, - variants?: Variants, - state: WebPressableState - scopedTheme?: string -} - -const extractStyleResult = (style: any) => { - return typeof style === 'function' - ? [style()] - : Array.isArray(style) - ? style.map(style => typeof style === 'function' ? style() : style) - : [style] -} - -const updateStyles = ({ ref, style, state, scopedTheme, variants }: UpdateStylesProps) => { - const styleResult = typeof style === 'function' - ? style(state) - : style - const extractedResult = extractStyleResult(styleResult) - const previousScopedTheme = UnistylesShadowRegistry.getScopedTheme() - const previousVariants = UnistylesShadowRegistry.getVariants() - - UnistylesShadowRegistry.selectVariants(variants as unknown as Variants) - UnistylesShadowRegistry.setScopedTheme(scopedTheme as any) - - UnistylesShadowRegistry.add(ref, extractedResult) - - UnistylesShadowRegistry.setScopedTheme(previousScopedTheme) - UnistylesShadowRegistry.selectVariants(previousVariants as unknown as Variants) -} - -export const Pressable = forwardRef(({ style, ...props }, passedRef) => { - const storedRef = useRef(null) +export const Pressable = forwardRef(({ style, ...props }, forwardedRef) => { const scopedTheme = UnistylesShadowRegistry.getScopedTheme() const variants = UnistylesShadowRegistry.getVariants() + let storedRef: HTMLElement | null = null + let classNames: ReturnType | undefined = undefined return ( { - if (!storedRef.current) { - return {} - } + ref={isServer() ? undefined : ref => { + storedRef = ref as unknown as HTMLElement + // @ts-expect-error hidden from TS + UnistylesShadowRegistry.add(storedRef, classNames?.hash) - updateStyles({ - ref: storedRef.current, - style: style as WebPressableStyle, - variants, - scopedTheme, - state: state as WebPressableState - }) + if (typeof forwardedRef === 'function') { + return forwardedRef(ref) + } - return {} + if (forwardedRef) { + forwardedRef.current = ref + } }} - ref={ref => { - storedRef.current = ref - updateStyles({ - ref, - style: style as WebPressableStyle, - variants, - scopedTheme, - state: initialState - }) + style={state => { + const styleResult = typeof style === 'function' + ? style(state as WebPressableState) + : style + const previousScopedTheme = UnistylesShadowRegistry.getScopedTheme() + const previousVariants = UnistylesShadowRegistry.getVariants() - if (typeof passedRef === 'object' && passedRef !== null) { - passedRef.current = ref - } + UnistylesShadowRegistry.selectVariants(variants) + UnistylesShadowRegistry.setScopedTheme(scopedTheme) + + // @ts-expect-error hidden from TS + UnistylesShadowRegistry.remove(storedRef, classNames?.hash) + classNames = getClassName(styleResult as UnistylesValues) + // @ts-expect-error hidden from TS + UnistylesShadowRegistry.add(storedRef, classNames?.hash) + + UnistylesShadowRegistry.selectVariants(previousVariants) + UnistylesShadowRegistry.setScopedTheme(previousScopedTheme) + + return classNames as any }} /> ) diff --git a/src/core/createUnistylesElement.native.tsx b/src/core/createUnistylesElement.native.tsx new file mode 100644 index 00000000..4dc4c2b1 --- /dev/null +++ b/src/core/createUnistylesElement.native.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { passForwardedRef } from './passForwardRef' + +export const createUnistylesElement = (Component: any) => React.forwardRef((props, forwardedRef) => ( + passForwardedRef(props, ref, forwardedRef)} + /> +)) diff --git a/src/core/createUnistylesElement.tsx b/src/core/createUnistylesElement.tsx index 4dc4c2b1..71e5d23a 100644 --- a/src/core/createUnistylesElement.tsx +++ b/src/core/createUnistylesElement.tsx @@ -1,9 +1,39 @@ import React from 'react' -import { passForwardedRef } from './passForwardRef' - -export const createUnistylesElement = (Component: any) => React.forwardRef((props, forwardedRef) => ( - passForwardedRef(props, ref, forwardedRef)} - /> -)) +import type { UnistylesValues } from '../types' +import { getClassName } from './getClassname' +import { isServer } from '../web/utils' +import { UnistylesShadowRegistry } from '../web' + +type ComponentProps = { + style?: UnistylesValues | Array +} + +export const createUnistylesElement = (Component: any) => React.forwardRef((props, forwardedRef) => { + let storedRef: HTMLElement | null = null + const classNames = getClassName(props.style) + + return ( + { + if (!ref) { + // @ts-expect-error hidden from TS + UnistylesShadowRegistry.remove(storedRef, classNames?.hash) + } + + storedRef = ref + // @ts-expect-error hidden from TS + UnistylesShadowRegistry.add(ref, classNames?.hash) + + if (typeof forwardedRef === 'function') { + return forwardedRef(ref) + } + + if (forwardedRef) { + forwardedRef.current = ref + } + }} + /> + ) +}) diff --git a/src/core/createUnistylesImageBackground.tsx b/src/core/createUnistylesImageBackground.tsx index 89bf2a69..1476688d 100644 --- a/src/core/createUnistylesImageBackground.tsx +++ b/src/core/createUnistylesImageBackground.tsx @@ -12,11 +12,11 @@ export const createUnistylesImageBackground = (Component: typeof ImageBackground ? props.imageStyle : [props.imageStyle] - // @ts-expect-error - This is hidden from TS + // @ts-expect-error web types are not compatible with RN styles UnistylesShadowRegistry.add(ref, style) return () => { - // @ts-expect-error - This is hidden from TS + // @ts-expect-error hidden from TS UnistylesShadowRegistry.remove(ref) } }} diff --git a/src/core/getClassname.ts b/src/core/getClassname.ts new file mode 100644 index 00000000..aff7821a --- /dev/null +++ b/src/core/getClassname.ts @@ -0,0 +1,17 @@ +import type { UnistylesValues } from '../types'; +import { deepMergeObjects } from '../utils'; +import { UnistylesShadowRegistry } from '../web'; + +export const getClassName = (unistyle: UnistylesValues | undefined | Array) => { + if (!unistyle) { + return undefined + } + + const style = Array.isArray(unistyle) + ? deepMergeObjects(...unistyle) + : unistyle + // @ts-expect-error hidden from TS + const { hash, injectedClassName } = UnistylesShadowRegistry.addStyles(style) + + return hash ? { $$css: true, hash, injectedClassName } : undefined +} diff --git a/src/core/index.ts b/src/core/index.ts index 5edc11b0..82cb767f 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -3,3 +3,4 @@ export { createUnistylesElement } from './createUnistylesElement' export { createUnistylesImageBackground } from './createUnistylesImageBackground' export { withUnistyles } from './withUnistyles' export { passForwardedRef } from './passForwardRef' +export { getClassName } from './getClassname' diff --git a/src/core/passForwardRef.ts b/src/core/passForwardRef.ts index 3ea8071b..07b800a8 100644 --- a/src/core/passForwardRef.ts +++ b/src/core/passForwardRef.ts @@ -19,10 +19,11 @@ export const passForwardedRef = ( } const forwardedRefReturnFn = passForwardedRef() + // @ts-expect-error hidden from TS UnistylesShadowRegistry.add(ref, props.style) return () => { - // @ts-expect-error - This is hidden from TS + // @ts-expect-error hidden from TS UnistylesShadowRegistry.remove(ref) forwardedRefReturnFn?.() } diff --git a/src/core/withUnistyles/withUnistyles.tsx b/src/core/withUnistyles/withUnistyles.tsx index 2b45c788..beca0448 100644 --- a/src/core/withUnistyles/withUnistyles.tsx +++ b/src/core/withUnistyles/withUnistyles.tsx @@ -1,42 +1,12 @@ -import React, { useEffect, useState, type ComponentType, forwardRef, useRef, useMemo, type ComponentProps, type ComponentRef } from 'react' +import React, { type ComponentType, forwardRef, type ComponentProps, type ComponentRef } from 'react' import type { PartialBy } from '../../types/common' -import { UnistylesListener } from '../../web/listener' -import { UnistylesShadowRegistry } from '../../web' -import { equal } from '../../web/utils' import { deepMergeObjects } from '../../utils' import type { Mappings, SupportedStyleProps } from './types' import { useDependencies } from './useDependencies' import { UnistyleDependency } from '../../specs/NativePlatform' import type { UnistylesValues } from '../../types' - -const useShadowRegistry = (style?: Record) => { - const [ref] = useState(document.createElement('div')) - const oldClassNames = useRef>([]) - const classNames = useMemo(() => { - if (!style) { - return [] - } - - const newClassNames = UnistylesShadowRegistry.add(ref, [style]) ?? [] - - if (equal(oldClassNames.current, newClassNames)) { - return oldClassNames.current - } - - oldClassNames.current = newClassNames - - return newClassNames - }, [style]) - - useEffect(() => () => { - // Remove styles on unmount - if (style) { - UnistylesShadowRegistry.add(null, [style]) - } - }) - - return classNames -} +import { getClassName } from '../getClassname' +import { UnistylesWeb } from '../../web' // @ts-expect-error type GenericComponentProps = ComponentProps @@ -53,11 +23,11 @@ export const withUnistyles = , PropsWithUnistyles>((props, ref) => { const narrowedProps = props as PropsWithUnistyles - const styleClassNames = useShadowRegistry(narrowedProps.style) - const contentContainerStyleClassNames = useShadowRegistry(narrowedProps.contentContainerStyle) + const styleClassNames = getClassName(narrowedProps.style) + const contentContainerStyleClassNames = getClassName(narrowedProps.contentContainerStyle) const { mappingsCallback } = useDependencies(({ dependencies, updateTheme, updateRuntime }) => { - const disposeTheme = UnistylesListener.addListeners(dependencies.filter(dependency => dependency === UnistyleDependency.Theme), updateTheme) - const disposeRuntime = UnistylesListener.addListeners(dependencies.filter(dependency => dependency !== UnistyleDependency.Theme), updateRuntime) + const disposeTheme = UnistylesWeb.listener.addListeners(dependencies.filter(dependency => dependency === UnistyleDependency.Theme), updateTheme) + const disposeRuntime = UnistylesWeb.listener.addListeners(dependencies.filter(dependency => dependency !== UnistyleDependency.Theme), updateRuntime) return () => { disposeTheme() @@ -70,16 +40,10 @@ export const withUnistyles = + } +} + +export const useServerUnistyles = () => { + const isServerInserted = useRef(false) + + useServerInsertedHTML(() => { + if (!isServerInserted.current) { + isServerInserted.current = true + + // @ts-ignore + const rnwStyle = StyleSheet?.getSheet().textContent ?? '' + const css = UnistylesWeb.registry.css.getStyles() + const state = UnistylesWeb.registry.css.getState() + UnistylesWeb.registry.reset() + + return ( + <> + + + + + ) + } + + return null + }) + + if (typeof window !== 'undefined') { + UnistylesWeb.registry.css.hydrate(window.__UNISTYLES_STATE__) + } +} diff --git a/src/utils.ts b/src/utils.ts index 34e87d70..fde9dd34 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,20 +1,24 @@ +export const isDefined = (value: T): value is NonNullable => value !== undefined && value !== null + export const deepMergeObjects = >(...sources: Array) => { const target = {} as T - sources.forEach(source => { - Object.keys(source).forEach(key => { - const sourceValue = source[key] - const targetValue = target[key] + sources + .filter(isDefined) + .forEach(source => { + Object.keys(source).forEach(key => { + const sourceValue = source[key] + const targetValue = target[key] - if (Object(sourceValue) === sourceValue && Object(targetValue) === targetValue) { - // @ts-expect-error - can't assign to generic - target[key] = deepMergeObjects(targetValue, sourceValue) + if (Object(sourceValue) === sourceValue && Object(targetValue) === targetValue) { + // @ts-expect-error - can't assign to generic + target[key] = deepMergeObjects(targetValue, sourceValue) - return - } + return + } - // @ts-expect-error - can't assign to generic - target[key] = sourceValue + // @ts-expect-error - can't assign to generic + target[key] = sourceValue }) }) diff --git a/src/web/convert/object/filter.ts b/src/web/convert/object/filter.ts index 3858da2f..89046a97 100644 --- a/src/web/convert/object/filter.ts +++ b/src/web/convert/object/filter.ts @@ -3,8 +3,8 @@ import { hyphenate } from '../../utils' import type { Filters } from '../types' import { getObjectStyle } from './objectStyle' import { normalizeColor, normalizeNumericValue } from '../utils' -import { UnistylesRuntime } from '../../runtime' import { isUnistylesMq } from '../../../mq' +import { UnistylesWeb } from '../../index' const getDropShadowStyle = (dropShadow: DropShadowValue) => { const { offsetX = 0, offsetY = 0, standardDeviation = 0, color = '#000' } = dropShadow @@ -21,7 +21,7 @@ export const getFilterStyle = (filters: Array) => { return [] } - const breakpoints = Object.keys(dropShadowValue).filter(key => Object.keys(UnistylesRuntime.breakpoints).includes(key) || isUnistylesMq(key)) + const breakpoints = Object.keys(dropShadowValue).filter(key => Object.keys(UnistylesWeb.runtime.breakpoints).includes(key) || isUnistylesMq(key)) const breakpointsDropShadow = Object.fromEntries(breakpoints.map(breakpoint => [breakpoint, getDropShadowStyle(dropShadowValue[breakpoint])])) if (breakpoints.length === 0) { diff --git a/src/web/convert/style.ts b/src/web/convert/style.ts index 60f16bfa..eb6f8f9a 100644 --- a/src/web/convert/style.ts +++ b/src/web/convert/style.ts @@ -1,15 +1,15 @@ import { keyInObject } from '../utils' -const SKIP_STYLES = [ +const SKIP_STYLES = new Set([ 'borderCurve', 'elevation', 'textAlignVertical', 'includeFontPadding', 'overlayColor', 'tintColor' -] +]) -const CSS_NUMBER_KEYS = [ +const CSS_NUMBER_KEYS = new Set([ 'animationIterationCount', 'borderImageOutset', 'borderImageSlice', @@ -54,7 +54,7 @@ const CSS_NUMBER_KEYS = [ 'strokeMiterlimit', 'strokeOpacity', 'strokeWidth' -] +]) const convertMap = { marginHorizontal: (value: number) => ({ @@ -124,14 +124,14 @@ const convertMap = { const convertNumber = (key: string, value: any) => { if (typeof value === 'number') { - return CSS_NUMBER_KEYS.includes(key, value) ? value : `${value}px` + return CSS_NUMBER_KEYS.has(key) ? value : `${value}px` } return value } export const getStyle = (key: string, value: any) => { - if (SKIP_STYLES.includes(key)) { + if (SKIP_STYLES.has(key)) { return {} } diff --git a/src/web/create.ts b/src/web/create.ts index 102ff4ba..46b56fc0 100644 --- a/src/web/create.ts +++ b/src/web/create.ts @@ -1,6 +1,6 @@ import type { StyleSheetWithSuperPowers, StyleSheet } from '../types/stylesheet' +import { UnistylesWeb } from './index' import { assignSecrets, error, removeInlineStyles } from './utils' -import { UnistylesRuntime } from './runtime' const useVariants = ['useVariants', () => {}] @@ -10,14 +10,13 @@ export const create = (stylesheet: StyleSheetWithSuperPowers, id?: s } const computedStylesheet = typeof stylesheet === 'function' - ? stylesheet(UnistylesRuntime.theme, UnistylesRuntime.miniRuntime) + ? stylesheet(UnistylesWeb.runtime.theme, UnistylesWeb.runtime.miniRuntime) : stylesheet const addSecrets = (value: any, key: string, args?: Array) => assignSecrets(value, { __uni__key: key, __uni__stylesheet: stylesheet, - __uni__args: args, - __uni__refs: new Set() + __uni__args: args }) const styleSheetStyles = Object.entries(computedStylesheet).map(([key, value]) => { diff --git a/src/web/css/core.ts b/src/web/css/core.ts new file mode 100644 index 00000000..7fc364f4 --- /dev/null +++ b/src/web/css/core.ts @@ -0,0 +1,58 @@ +import { getMediaQuery } from '../utils' +import type { CSSState } from './state' + +export const convertToCSS = (hash: string, value: Record, state: CSSState) => { + Object.entries(value).forEach(([styleKey, styleValue]) => { + if (styleKey[0] === '_') { + const pseudoClassName = styleKey.replace('_', `${hash}:`) + + Object.entries(styleValue).forEach(([pseudoStyleKey, pseudoStyleValue]) => { + if (typeof pseudoStyleValue === 'object' && pseudoStyleValue !== null) { + const allBreakpoints = Object.keys(pseudoStyleValue) + Object.entries(pseudoStyleValue).forEach(([breakpointStyleKey, breakpointStyleValue]) => { + const mediaQuery = getMediaQuery(pseudoStyleKey, allBreakpoints) + + state.set({ + mediaQuery, + className: pseudoClassName, + propertyKey: breakpointStyleKey, + value: breakpointStyleValue, + }) + }) + + return + } + + state.set({ + className: pseudoClassName, + propertyKey: pseudoStyleKey, + value: pseudoStyleValue, + }) + }) + + return + } + + if (typeof styleValue === 'object') { + const allBreakpoints = Object.keys(styleValue) + Object.entries(styleValue).forEach(([breakpointStyleKey, breakpointStyleValue]) => { + const mediaQuery = getMediaQuery(styleKey, allBreakpoints) + + state.set({ + mediaQuery, + className: hash, + propertyKey: breakpointStyleKey, + value: breakpointStyleValue, + }) + }) + + return + } + + state.set({ + className: hash, + propertyKey: styleKey, + value: styleValue, + }) + }) +} diff --git a/src/web/css/index.ts b/src/web/css/index.ts new file mode 100644 index 00000000..255e080b --- /dev/null +++ b/src/web/css/index.ts @@ -0,0 +1 @@ +export * from './state' diff --git a/src/web/css/state.ts b/src/web/css/state.ts new file mode 100644 index 00000000..08ac7759 --- /dev/null +++ b/src/web/css/state.ts @@ -0,0 +1,165 @@ +import type { UnistylesValues } from '../../types' +import { convertUnistyles } from '../convert' +import { hyphenate, isServer } from '../utils' +import { convertToCSS } from './core' + +type MapType = Map>> +type SetProps = { + mediaQuery?: string + className: string + isMq?: boolean + propertyKey: string + value: any +} +type HydrateState = Array<[ string, Array<[ string, Array<[ string, any ]> ]> ]> + +const safeGetMap = (map: Map>, key: string) => { + const nextLevelMap = map.get(key) + + if (!nextLevelMap) { + const newMap = new Map() + + map.set(key, newMap) + + return newMap + } + + return nextLevelMap +} + +export class CSSState { + mainMap: MapType = new Map() + mqMap: MapType = new Map() + private styleTag: HTMLStyleElement | null = null + + constructor() { + if (isServer()) { + return + } + + const ssrTag = document.getElementById('unistyles-web') + + if (ssrTag) { + this.styleTag = ssrTag as HTMLStyleElement + + return + } + + this.styleTag = document.createElement('style') + this.styleTag.id = 'unistyles-web' + document.head.appendChild(this.styleTag) + } + + set = ({ className, propertyKey, value, mediaQuery = '', isMq }: SetProps) => { + const firstLevelMap = isMq ? this.mqMap : this.mainMap + const secondLevelMap = safeGetMap(firstLevelMap, mediaQuery) + const thirdLevelMap = safeGetMap(secondLevelMap, className) + + thirdLevelMap.set(propertyKey, value) + } + + add = (hash: string, values: UnistylesValues) => { + convertToCSS(hash, convertUnistyles(values), this) + + if (this.styleTag) { + this.styleTag.innerText = this.getStyles() + } + } + + remove = (hash: string) => { + this.mainMap.forEach(styles => { + styles.delete(hash) + }) + + if (this.styleTag) { + this.styleTag.innerText = this.getStyles() + } + } + + getStyles = () => { + let styles = '' + + const generate = (mediaQuery: string, secondLevelMap: Map>) => { + if (mediaQuery) { + styles += `${mediaQuery}{` + } + + for (const [className, thirdLevelMap] of secondLevelMap) { + styles += `.${className}{` + + for (const [propertyKey, value] of thirdLevelMap) { + if (value === undefined) { + continue + } + + styles += `${hyphenate(propertyKey)}:${value};` + } + + styles += '}' + } + + if (mediaQuery) { + styles += '}' + } + } + + for (const [mediaQuery, secondLevelMap] of this.mainMap) { + generate(mediaQuery, secondLevelMap) + } + + for (const [mediaQuery, secondLevelMap] of this.mqMap) { + generate(mediaQuery, secondLevelMap) + } + + return styles + } + + getState = () => { + const getState = (map: MapType) => { + return Array.from(map).map(([mediaQuery, classNames]) => { + return [ + mediaQuery, + Array.from(classNames.entries()).map(([className, style]) => { + return [ + className, + Array.from(style.entries()).map(([property, value]) => { + return [property, value] + }) + ] + }) + ] + }) as HydrateState + } + + const mainState = getState(this.mainMap) + const mqState = getState(this.mqMap) + + return { mainState, mqState } + } + + hydrate = ({ mainState, mqState }: ReturnType) => { + const hydrateState = (map: HydrateState, isMq = false) => { + map.forEach(([mediaQuery, classNames]) => { + classNames.forEach(([className, style]) => { + style.forEach(([propertyKey, value]) => { + this.set({ + className, + propertyKey, + value, + mediaQuery, + isMq + }) + }) + }) + }) + } + + hydrateState(mainState) + hydrateState(mqState, true) + } + + reset = () => { + this.mqMap.clear() + this.mainMap.clear() + } +} diff --git a/src/web/css/utils.ts b/src/web/css/utils.ts new file mode 100644 index 00000000..55ca5629 --- /dev/null +++ b/src/web/css/utils.ts @@ -0,0 +1,54 @@ +import { hyphenate } from '../utils' +import type { CSSState } from './state' + +export const safeGetMap = (map: Map>, key: string) => { + const nextLevelMap = map.get(key) + + if (!nextLevelMap) { + const newMap = new Map() + + map.set(key, newMap) + + return newMap + } + + return nextLevelMap +} + +export const getStylesFromState = (state: CSSState) => { + let styles = '' + + const generate = (mediaQuery: string, secondLevelMap: Map>) => { + if (mediaQuery) { + styles += `${mediaQuery}{` + } + + for (const [className, thirdLevelMap] of secondLevelMap) { + styles += `.${className}{` + + for (const [propertyKey, value] of thirdLevelMap) { + if (value === undefined) { + continue + } + + styles += `${hyphenate(propertyKey)}:${value};` + } + + styles += '}' + } + + if (mediaQuery) { + styles += '}' + } + } + + for (const [mediaQuery, secondLevelMap] of state.mainMap) { + generate(mediaQuery, secondLevelMap) + } + + for (const [mediaQuery, secondLevelMap] of state.mqMap) { + generate(mediaQuery, secondLevelMap) + } + + return styles +} diff --git a/src/web/index.ts b/src/web/index.ts index 28607f36..76482fb1 100644 --- a/src/web/index.ts +++ b/src/web/index.ts @@ -1,12 +1,24 @@ import { create } from './create' -import { UnistylesState } from './state' import { deepMergeObjects } from '../utils' import type { StyleSheet as NativeStyleSheet } from '../specs/StyleSheet' -import { UnistylesRuntime as UnistylesRuntimeWeb } from './runtime' import type { Runtime as NativeUnistylesRuntime } from '../specs/UnistylesRuntime' +import type { UnistylesShadowRegistry as NativeUnistylesShadowRegistry } from '../specs/ShadowRegistry' +import { UnistylesServices } from './services' +import { isServer } from './utils' +declare global { + // @ts-ignore + var __unistyles__: UnistylesServices +} + +if (isServer() && !globalThis.__unistyles__) { + // @ts-ignore + globalThis.__unistyles__ = new UnistylesServices() +} + +export const UnistylesWeb = isServer() ? globalThis.__unistyles__ : new UnistylesServices() export const StyleSheet = { - configure: UnistylesState.init, + configure: UnistylesWeb.state.init, create: create, absoluteFill: { position: 'absolute', @@ -27,7 +39,8 @@ export const StyleSheet = { hairlineWidth: 1 } as unknown as typeof NativeStyleSheet -export const UnistylesRuntime = UnistylesRuntimeWeb as unknown as typeof NativeUnistylesRuntime +export const UnistylesRuntime = UnistylesWeb.runtime as unknown as typeof NativeUnistylesRuntime +export const UnistylesShadowRegistry = UnistylesWeb.shadowRegistry as unknown as typeof NativeUnistylesShadowRegistry export * from './mock' -export * from './shadowRegistry' + diff --git a/src/web/listener.ts b/src/web/listener.ts index 691ed0c5..6757851c 100644 --- a/src/web/listener.ts +++ b/src/web/listener.ts @@ -1,11 +1,13 @@ import { UnistyleDependency } from '../specs/NativePlatform' -import { UnistylesRuntime } from './runtime' +import type { UnistylesServices } from './types' -class UnistylesListenerBuilder { +export class UnistylesListener { private isInitialized = false private listeners = Array.from({ length: Object.keys(UnistyleDependency).length / 2 }, () => new Set()) private stylesheetListeners = Array.from({ length: Object.keys(UnistyleDependency).length / 2 }, () => new Set()) + constructor(private services: UnistylesServices) {} + emitChange = (dependency: UnistyleDependency) => { this.stylesheetListeners[dependency]?.forEach(listener => listener()) this.listeners[dependency]?.forEach(listener => listener()) @@ -18,25 +20,25 @@ class UnistylesListenerBuilder { this.isInitialized = true - UnistylesRuntime.darkMedia?.addEventListener('change', event => { + this.services.runtime.darkMedia?.addEventListener('change', event => { if (!event.matches) { return } this.emitChange(UnistyleDependency.ColorScheme) - if (UnistylesRuntime.hasAdaptiveThemes) { + if (this.services.runtime.hasAdaptiveThemes) { this.emitChange(UnistyleDependency.Theme) } }) - UnistylesRuntime.lightMedia?.addEventListener('change', event => { + this.services.runtime.lightMedia?.addEventListener('change', event => { if (!event.matches) { return } this.emitChange(UnistyleDependency.ColorScheme) - if (UnistylesRuntime.hasAdaptiveThemes) { + if (this.services.runtime.hasAdaptiveThemes) { this.emitChange(UnistyleDependency.Theme) } }) @@ -61,5 +63,3 @@ class UnistylesListenerBuilder { } } } - -export const UnistylesListener = new UnistylesListenerBuilder() diff --git a/src/web/registry.ts b/src/web/registry.ts index 819fb007..38a36c94 100644 --- a/src/web/registry.ts +++ b/src/web/registry.ts @@ -1,42 +1,19 @@ import type { UnistylesTheme, UnistylesValues } from '../types' import type { StyleSheet, StyleSheetWithSuperPowers } from '../types/stylesheet' -import { UnistylesRuntime } from './runtime' -import { extractMediaQueryValue, keyInObject, getMediaQuery, generateHash, extractUnistyleDependencies, error } from './utils' -import { UnistylesListener } from './listener' -import { convertUnistyles } from './convert' +import { generateHash, extractUnistyleDependencies, error } from './utils' import type { UnistylesMiniRuntime, UnistyleDependency } from '../specs' +import { CSSState } from './css' +import type { UnistylesServices } from './types' -type ApplyRuleProps = { - hash: string, - key: string, - value: any, - sheet: CSSStyleSheet | CSSMediaRule -} - -type RemoveReadonlyStyleKeys = T extends 'length' | 'parentRule' ? never : T - -class UnistylesRegistryBuilder { +export class UnistylesRegistry { private readonly stylesheets = new Map, StyleSheet>() - private readonly stylesCounter = new Map>() - #styleTag: HTMLStyleElement | null = null + private readonly stylesCache = new Set() + private readonly stylesCounter = new Map>() private readonly disposeListenersMap = new Map() private readonly dependenciesMap = new Map, Set>() + readonly css = new CSSState() - private get styleTag() { - const tag = this.#styleTag - - if (!tag) { - const newTag = document.createElement('style') - - newTag.id = 'unistyles-web' - this.#styleTag = newTag - document.head.appendChild(newTag) - - return newTag - } - - return tag - } + constructor(private services: UnistylesServices) {} getComputedStylesheet = (stylesheet: StyleSheetWithSuperPowers, scopedThemeName?: UnistylesTheme) => { if (typeof stylesheet !== 'function') { @@ -44,13 +21,13 @@ class UnistylesRegistryBuilder { } if (scopedThemeName) { - const scopedTheme = UnistylesRuntime.getTheme(scopedThemeName) + const scopedTheme = this.services.runtime.getTheme(scopedThemeName) if (!scopedTheme) { throw error(`Unistyles: You're trying to use scoped theme '${scopedThemeName}' but it wasn't registered.`) } - return stylesheet(scopedTheme, UnistylesRuntime.miniRuntime) + return stylesheet(scopedTheme, this.services.runtime.miniRuntime) } const computedStylesheet = this.stylesheets.get(stylesheet) @@ -59,7 +36,7 @@ class UnistylesRegistryBuilder { return computedStylesheet } - const createdStylesheet = stylesheet(UnistylesRuntime.theme, UnistylesRuntime.miniRuntime) + const createdStylesheet = stylesheet(this.services.runtime.theme, this.services.runtime.miniRuntime) const dependencies = Object.values(createdStylesheet).flatMap(value => extractUnistyleDependencies(value)) this.addDependenciesToStylesheet(stylesheet, dependencies) @@ -75,8 +52,8 @@ class UnistylesRegistryBuilder { dependencies.forEach(dependency => dependenciesMap.add(dependency)) - const dispose = UnistylesListener.addStylesheetListeners(Array.from(dependenciesMap), () => { - const newComputedStylesheet = stylesheet(UnistylesRuntime.theme, UnistylesRuntime.miniRuntime) + const dispose = this.services.listener.addStylesheetListeners(Array.from(dependenciesMap), () => { + const newComputedStylesheet = stylesheet(this.services.runtime.theme, this.services.runtime.miniRuntime) this.stylesheets.set(stylesheet, newComputedStylesheet) }) @@ -85,140 +62,50 @@ class UnistylesRegistryBuilder { this.disposeListenersMap.set(stylesheet, dispose) } - add = (value: UnistylesValues) => { - const hash = generateHash(value) - const existingCounter = this.stylesCounter.get(hash) + connect = (ref: HTMLElement, hash: string) => { + const stylesCounter = this.stylesCounter.get(hash) ?? new Set() - if (!existingCounter || existingCounter.size === 0) { - const counter = new Set() - - counter.add(value) - this.stylesCounter.set(hash, counter) - this.applyStyles(hash, value) - - return { hash, existingHash: false } - } - - existingCounter.add(value) - - return { hash, existingHash: true } + stylesCounter.add(ref) + this.stylesCounter.set(hash, stylesCounter) } - applyStyles = (hash: string, styles: Record) => { - Object.entries(convertUnistyles(styles)).forEach(([key, value]) => { - if (!this.styleTag.sheet) { - return - } + remove = (ref: HTMLElement, hash: string) => { + const stylesCounter = this.stylesCounter.get(hash) ?? new Set() - if (typeof value === 'object' && !key.startsWith('_')) { - const mediaQuery = getMediaQuery(key) - const cssRules = Array.from(this.styleTag.sheet.cssRules) - let queryRule = cssRules.find(rule => { - if (!(rule instanceof CSSMediaRule)) { - return false - } - - return rule.media.item(0)?.includes(mediaQuery) - }) ?? null - - if (!queryRule) { - const mediaQueryValue = extractMediaQueryValue(mediaQuery) - const ruleIndex = mediaQueryValue - ? cssRules.reduce((acc, rule, ruleIndex) => { - if (!(rule instanceof CSSMediaRule)) { - return acc - } - - const ruleMediaQueryValue = extractMediaQueryValue(rule.conditionText) - - if (ruleMediaQueryValue === undefined) { - return - } - - return ruleMediaQueryValue > mediaQueryValue ? ruleIndex : acc - }, cssRules.length) - : undefined - queryRule = this.styleTag.sheet.cssRules.item(this.styleTag.sheet.insertRule(`@media ${mediaQuery} {.${hash} {}}`, ruleIndex)) - } - - if (queryRule instanceof CSSMediaRule) { - Object.entries(value).forEach(([mqKey, mqValue]) => { - this.applyRule({ - hash, - key: mqKey, - value: mqValue, - sheet: queryRule - }) - }) - } - - return - } + stylesCounter.delete(ref) - // Pseudo - if (typeof value === 'object') { - Object.entries(value).forEach(([pseudoKey, pseudoValue]) => { - this.applyRule({ - hash: `${hash}${key.replace('_', ':')}`, - key: pseudoKey, - value: pseudoValue, - sheet: this.styleTag.sheet as CSSStyleSheet - }) - }) - - return - } - - this.applyRule({ - hash, - key, - value, - sheet: this.styleTag.sheet - }) - }) - } - - private applyRule = ({ hash, key, value, sheet }: ApplyRuleProps) => { - let rule = Array.from(sheet.cssRules).find(rule => { - if (!(rule instanceof CSSStyleRule)) { - return false - } - - // In unistyles pseudos are prefixed with ':' but in css some of them are prefixed with '::' - return rule.selectorText.replace('::', ':').includes(hash) - }) ?? null + if (stylesCounter.size === 0 && !document.querySelector(`.${hash}`)) { + this.css.remove(hash) + this.stylesCache.delete(hash) - if (!rule) { - rule = sheet.cssRules.item(sheet.insertRule(`.${hash} {}`)) + return true } - if (!(rule instanceof CSSStyleRule) || !keyInObject(rule.style, key)) { - return - } - - rule.style[key as RemoveReadonlyStyleKeys] = value + return false } - remove = (value: UnistylesValues) => { + add = (value: UnistylesValues) => { const hash = generateHash(value) - const existingStyles = this.stylesCounter.get(hash) - if (!existingStyles) { - return - } + if (!this.stylesCache.has(hash)) { + this.applyStyles(hash, value) + this.stylesCache.add(hash) - existingStyles.delete(value) + return { hash, existingHash: false } + } - if (existingStyles.size === 0) { - const ruleIndex = Array.from(this.styleTag.sheet?.cssRules ?? []).findIndex(rule => rule.cssText.includes(`.${hash}`)) + return { hash, existingHash: true } + } - if (ruleIndex === -1) { - return - } + applyStyles = (hash: string, value: UnistylesValues) => { + this.css.add(hash, value) + } - this.styleTag.sheet?.deleteRule(ruleIndex) - } + reset = () => { + this.css.reset() + this.stylesCache.clear() + this.dependenciesMap.clear() + this.disposeListenersMap.clear() + this.stylesCounter.clear() } } - -export const UnistylesRegistry = new UnistylesRegistryBuilder() diff --git a/src/web/runtime.ts b/src/web/runtime.ts index fdd078c3..bc933439 100644 --- a/src/web/runtime.ts +++ b/src/web/runtime.ts @@ -2,17 +2,16 @@ import { UnistyleDependency } from '../specs/NativePlatform' import { ColorScheme, Orientation, type AppTheme, type AppThemeName } from '../specs/types' import type { UnistylesMiniRuntime } from '../specs/UnistylesRuntime' import { WebContentSizeCategory } from '../types' -import { UnistylesListener } from './listener' import { NavigationBar, StatusBar } from './mock' import { error, isServer, schemeToTheme } from './utils' +import type { UnistylesServices } from './types' -// Keep this import here, otherwise circular dependency will occur and break the build -import { UnistylesState } from './state' - -class UnistylesRuntimeBuilder { +export class UnistylesRuntime { lightMedia = this.getLightMedia() darkMedia = this.getDarkMedia() + constructor(private services: UnistylesServices) {} + private getLightMedia(): MediaQueryList | null { if (isServer()) { return null @@ -49,11 +48,11 @@ class UnistylesRuntimeBuilder { } get themeName() { - if (UnistylesState.hasAdaptiveThemes) { + if (this.services.state.hasAdaptiveThemes) { return schemeToTheme(this.colorScheme) as AppThemeName } - return UnistylesState.themeName + return this.services.state.themeName } get contentSizeCategory() { @@ -61,11 +60,11 @@ class UnistylesRuntimeBuilder { } get breakpoints() { - return UnistylesState.breakpoints ?? {} + return this.services.state.breakpoints ?? {} } get breakpoint() { - return UnistylesState.breakpoint + return this.services.state.breakpoint } get orientation() { @@ -121,7 +120,7 @@ class UnistylesRuntimeBuilder { } get hasAdaptiveThemes() { - return UnistylesState.hasAdaptiveThemes + return this.services.state.hasAdaptiveThemes } get navigationBar() { @@ -158,23 +157,23 @@ class UnistylesRuntimeBuilder { throw error(`You're trying to set theme to: '${themeName}', but adaptiveThemes are enabled.`) } - if (themeName === UnistylesRuntime.themeName) { + if (themeName === this.themeName) { return } - UnistylesState.themeName = themeName - UnistylesListener.emitChange(UnistyleDependency.Theme) - UnistylesListener.emitChange(UnistyleDependency.ThemeName) + this.services.state.themeName = themeName + this.services.listener.emitChange(UnistyleDependency.Theme) + this.services.listener.emitChange(UnistyleDependency.ThemeName) } setAdaptiveThemes = (isEnabled: boolean) => { - UnistylesState.hasAdaptiveThemes = isEnabled + this.services.state.hasAdaptiveThemes = isEnabled if (!isEnabled) { return } - this.setTheme(schemeToTheme(UnistylesRuntime.colorScheme) as AppThemeName) + this.setTheme(schemeToTheme(this.colorScheme) as AppThemeName) } setRootViewBackgroundColor = (color: string) => { @@ -188,17 +187,17 @@ class UnistylesRuntimeBuilder { setImmersiveMode = () => {} updateTheme = (themeName: AppThemeName, updater: (currentTheme: AppTheme) => AppTheme) => { - const oldTheme = UnistylesState.themes.get(themeName) + const oldTheme = this.services.state.themes.get(themeName) if (!oldTheme) { throw error(`Unistyles: You're trying to update theme "${themeName}" but it wasn't registered.`) } - UnistylesState.themes.set(themeName, updater(oldTheme)) + this.services.state.themes.set(themeName, updater(oldTheme)) } getTheme = (themeName = this.themeName) => { - const theme = UnistylesState.themes.get(themeName ?? '') + const theme = this.services.state.themes.get(themeName ?? '') if (!themeName || !theme) { throw error(`You're trying to get theme "${themeName}" but it wasn't registered.`) @@ -207,5 +206,3 @@ class UnistylesRuntimeBuilder { return theme } } - -export const UnistylesRuntime = new UnistylesRuntimeBuilder() diff --git a/src/web/services.ts b/src/web/services.ts new file mode 100644 index 00000000..60ae699d --- /dev/null +++ b/src/web/services.ts @@ -0,0 +1,28 @@ +import { UnistylesListener } from './listener' +import { UnistylesRegistry } from './registry' +import { UnistylesRuntime } from './runtime' +import { UnistylesShadowRegistry } from './shadowRegistry' +import { UnistylesState } from './state' + +export class UnistylesServices { + runtime: UnistylesRuntime + registry: UnistylesRegistry + shadowRegistry: UnistylesShadowRegistry + state: UnistylesState + listener: UnistylesListener + + private services = {} as UnistylesServices + + constructor() { + this.runtime = new UnistylesRuntime(this.services) + this.registry = new UnistylesRegistry(this.services) + this.shadowRegistry = new UnistylesShadowRegistry(this.services) + this.state = new UnistylesState(this.services) + this.listener = new UnistylesListener(this.services) + this.services.runtime = this.runtime + this.services.registry = this.registry + this.services.shadowRegistry = this.shadowRegistry + this.services.state = this.state + this.services.listener = this.listener + } +} diff --git a/src/web/shadowRegistry.ts b/src/web/shadowRegistry.ts index 74340a56..057d44bf 100644 --- a/src/web/shadowRegistry.ts +++ b/src/web/shadowRegistry.ts @@ -1,13 +1,10 @@ import type { UnistylesTheme, UnistylesValues } from '../types' -import { UnistylesListener } from './listener' -import { UnistylesRegistry } from './registry' import { deepMergeObjects } from '../utils' -import { equal, extractSecrets, extractUnistyleDependencies, isInDocument } from './utils' +import { extractSecrets, extractUnistyleDependencies } from './utils' import { getVariants } from './variants' +import type { UnistylesServices } from './types' -type Style = UnistylesValues | ((...args: Array) => UnistylesValues) - -class UnistylesShadowRegistryBuilder { +export class UnistylesShadowRegistry { // MOCKS name = 'UnistylesShadowRegistry' __type = 'web' @@ -16,119 +13,63 @@ class UnistylesShadowRegistryBuilder { dispose = () => {} // END MOCKS - private resultsMap = new Map() - private classNamesMap = new Map>() private selectedVariants = new Map() private scopedTheme: UnistylesTheme | undefined = undefined - private disposeMap = new Map() - - add = (ref: any, styles: Array