diff --git a/package.json b/package.json index d9c5485..712c286 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "pnpm": { "overrides": { "jacaranda": "workspace:*" - } + }, + "ignoredBuiltDependencies": [ + "@swc/core", + "esbuild" + ] } } diff --git a/packages/jacaranda/__mocks__/@testing-library/react-native.ts b/packages/jacaranda/__mocks__/@testing-library/react-native.ts new file mode 100644 index 0000000..220dbcd --- /dev/null +++ b/packages/jacaranda/__mocks__/@testing-library/react-native.ts @@ -0,0 +1,143 @@ +// Manual mock for @testing-library/react-native +// Provides basic render function for testing styled components +import React from 'react'; +import type { ReactElement, ComponentType } from 'react'; + +interface RenderResult { + toJSON: () => unknown; + getByTestId: (testId: string) => { props: Record }; + queryByTestId: (testId: string) => { props: Record } | null; + unmount: () => void; + rerender: (ui: ReactElement) => void; +} + +// Recursively render component tree to get final props +const renderToProps = (element: ReactElement): ReactElement | null => { + if (!element || typeof element !== 'object') return null; + + const { type, props } = element; + + // If it's a function component, call it to get the rendered output + if (typeof type === 'function') { + try { + // Check if it's a class component + if (type.prototype && type.prototype.isReactComponent) { + const instance = new (type as any)(props); + const rendered = instance.render(); + return renderToProps(rendered); + } + // Function component + const rendered = (type as any)(props); + return renderToProps(rendered); + } catch { + // If rendering fails, return the element as-is + return element; + } + } + + // If it's a memo component, unwrap it + if (type && typeof type === 'object' && '$$typeof' in type) { + const memoType = type as { $$typeof: symbol; type?: ComponentType }; + if (memoType.type && typeof memoType.type === 'function') { + try { + const rendered = (memoType.type as any)(props); + return renderToProps(rendered); + } catch { + return element; + } + } + } + + // For host components (strings like 'View', 'Text'), return the element + if (typeof type === 'string') { + return element; + } + + // For forwardRef components + if (type && typeof type === 'object' && 'render' in type) { + try { + const rendered = (type as any).render(props, null); + return renderToProps(rendered); + } catch { + return element; + } + } + + return element; +}; + +const findByTestId = (element: ReactElement | null, testId: string): ReactElement | null => { + if (!element) return null; + + // First, try to render the element to get its actual output + const rendered = renderToProps(element); + if (!rendered) return null; + + // Check if this element has the testID + if (rendered.props?.testID === testId) { + return rendered; + } + + // Search in children + const children = rendered.props?.children; + if (children) { + if (Array.isArray(children)) { + for (const child of children) { + if (React.isValidElement(child)) { + const found = findByTestId(child as ReactElement, testId); + if (found) return found; + } + } + } else if (React.isValidElement(children)) { + return findByTestId(children as ReactElement, testId); + } + } + + return null; +}; + +export const render = (ui: ReactElement): RenderResult => { + let currentUi = ui; + + return { + toJSON: () => { + const rendered = renderToProps(currentUi); + return rendered; + }, + getByTestId: (testId: string) => { + const found = findByTestId(currentUi, testId); + if (!found) { + throw new Error(`Unable to find element with testID: ${testId}`); + } + return { props: { ...found.props } }; + }, + queryByTestId: (testId: string) => { + const found = findByTestId(currentUi, testId); + return found ? { props: { ...found.props } } : null; + }, + unmount: () => {}, + rerender: (newUi: ReactElement) => { + currentUi = newUi; + }, + }; +}; + +export const screen = { + getByTestId: () => { + throw new Error('screen.getByTestId requires render() to be called first'); + }, +}; + +export const fireEvent = { + press: () => {}, + changeText: () => {}, + scroll: () => {}, +}; + +export const waitFor = async (callback: () => T | Promise): Promise => { + return callback(); +}; + +export const act = async (callback: () => void | Promise): Promise => { + await callback(); +}; diff --git a/packages/jacaranda/__mocks__/react-native.ts b/packages/jacaranda/__mocks__/react-native.ts new file mode 100644 index 0000000..8300637 --- /dev/null +++ b/packages/jacaranda/__mocks__/react-native.ts @@ -0,0 +1,55 @@ +// Manual mock for react-native +// This allows testing without the actual react-native package +import React from 'react'; + +// Mock StyleSheet +export const StyleSheet = { + create: >(styles: T): T => styles, + flatten: (style: unknown) => style, + compose: (style1: unknown, style2: unknown) => [style1, style2], + absoluteFill: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }, + absoluteFillObject: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }, + hairlineWidth: 1, +}; + +// Create basic component mocks +const createMockComponent = (name: string) => { + const Component = React.forwardRef((props: any, ref: any) => { + return React.createElement(name, { ...props, ref }, props.children); + }); + Component.displayName = name; + return Component; +}; + +export const View = createMockComponent('View'); +export const Text = createMockComponent('Text'); +export const Image = createMockComponent('Image'); +export const TouchableOpacity = createMockComponent('TouchableOpacity'); +export const TouchableHighlight = createMockComponent('TouchableHighlight'); +export const TouchableWithoutFeedback = createMockComponent('TouchableWithoutFeedback'); +export const ScrollView = createMockComponent('ScrollView'); +export const TextInput = createMockComponent('TextInput'); + +export const Platform = { + OS: 'ios' as const, + select: (obj: { ios?: T; android?: T; default?: T }): T | undefined => obj.ios ?? obj.default, +}; + +// Type exports +export type ViewStyle = Record; +export type TextStyle = Record; +export type ImageStyle = Record; +export type StyleProp = T | T[] | null | undefined; + +export default { + StyleSheet, + View, + Text, + Image, + TouchableOpacity, + TouchableHighlight, + TouchableWithoutFeedback, + ScrollView, + TextInput, + Platform, +}; diff --git a/packages/jacaranda/package.json b/packages/jacaranda/package.json index 85f39a6..418627a 100644 --- a/packages/jacaranda/package.json +++ b/packages/jacaranda/package.json @@ -1,7 +1,7 @@ { "name": "jacaranda", "description": "A lightweight styling library for React Native", - "version": "0.1.0", + "version": "0.1.1-beta.2", "license": "ISC", "type": "module", "author": { diff --git a/packages/jacaranda/src/index.ts b/packages/jacaranda/src/index.ts index fb7c201..ed7e6a2 100644 --- a/packages/jacaranda/src/index.ts +++ b/packages/jacaranda/src/index.ts @@ -45,9 +45,33 @@ // position: props.otherProp ? 'absolute' : 'relative'; // }); -import React, { type ComponentType, type ReactNode } from 'react'; +import React, { type ComponentType, type ReactNode, memo } from 'react'; import { ImageStyle, TextStyle, ViewStyle, StyleProp, StyleSheet } from 'react-native'; +/** + * Performance optimization: Fast cache key generation + * Uses manual string building instead of array methods for better performance + * Pre-sorted keys array avoids runtime sorting + */ +const generateCacheKeyFast = ( + props: Record | undefined, + sortedKeys: readonly string[], +): string => { + if (!props) return ''; + let key = ''; + for (let i = 0; i < sortedKeys.length; i++) { + const k = sortedKeys[i]; + const v = props[k]; + if (v !== undefined) { + if (key) key += '|'; + key += k; + key += ':'; + key += v; + } + } + return key; +}; + // Define a type that removes token strings from style properties type ResolvedStyle = ViewStyle & TextStyle & ImageStyle; @@ -111,6 +135,11 @@ export type VariantProps = T extends (props?: infer P) => StyleProp, Tokens extends TokenConfig = TokenConfig>( config: VariantStyleConfig, @@ -119,79 +148,123 @@ function styles, Tokens extends TokenConfig type DefaultProps = NonNullable; type Props = OptionalIfHasDefault; + // Performance: Direct Map access (no wrapper overhead) + const styleCache = new Map>(); + + // Performance: Pre-sort variant keys once at definition time + const variantKeys = Object.keys(config.variants).sort() as (keyof V)[]; + + // Performance: Pre-compute default props object once + const defaultVariants = config.defaultVariants || ({} as DefaultVariants); + + // Performance: Pre-extract variant groups for faster access + const variantGroups = config.variants; + + // Performance: Pre-compute compound variant matchers + const compoundMatchers = config.compoundVariants?.map((compound) => { + const conditions = Object.entries(compound.variants); + const conditionKeys = conditions.map(([k]) => k); + const conditionValues = conditions.map(([, v]) => v); + const isArrayFlags = conditionValues.map((v) => Array.isArray(v)); + return { conditions, conditionKeys, conditionValues, isArrayFlags, style: compound.style }; + }); + + // Performance: Pre-compute base style + const baseStyle = config.base || {}; + return (props?: Props): StyleProp => { - // We'll build up styles in the correct hierarchy: base → variants → compound variants - // Base styles (lowest priority) - let stylesObj: StyleObject = { - ...(config.base || {}), - }; + // Performance: Fast cache key generation using pre-sorted keys + const cacheKey = generateCacheKeyFast( + props as Record, + variantKeys as string[], + ); + + // Performance: Direct Map.get (no wrapper function call) + const cachedStyle = styleCache.get(cacheKey); + if (cachedStyle !== undefined) { + return cachedStyle; + } - // Merge default variants with provided props - const mergedProps = { - ...(config.defaultVariants || {}), - ...props, - } as VariantProps; - - // Apply variant styles (overrides base styles) - for (const [propName, value] of Object.entries(mergedProps) as [ - keyof V, - keyof VariantProps[keyof V] | boolean, - ][]) { - const variantGroup = config.variants[propName]; - if (variantGroup) { - // Handle boolean variants - if (typeof value === 'boolean') { - const booleanValue: BooleanVariantKey = value ? 'true' : 'false'; - if (variantGroup[booleanValue as keyof typeof variantGroup]) { - stylesObj = { - ...stylesObj, - ...variantGroup[booleanValue as keyof typeof variantGroup], - }; - } - } else { - // Handle string/enum variants - const variantValue = value || config.defaultVariants?.[propName]; - if (variantValue && variantGroup[variantValue as keyof typeof variantGroup]) { - stylesObj = { - ...stylesObj, - ...variantGroup[variantValue as keyof typeof variantGroup], - }; - } + // Build styles - start with base + const stylesObj: Record = {}; + + // Copy base styles + for (const key in baseStyle) { + stylesObj[key] = baseStyle[key as keyof typeof baseStyle]; + } + + // Performance: Apply variant styles using pre-extracted data + for (let i = 0; i < variantKeys.length; i++) { + const propName = variantKeys[i]; + const variantGroup = variantGroups[propName]; + + // Get prop value with fallback to default (use unknown to avoid complex type inference) + let value: unknown = (props as Record)?.[propName as string]; + if (value === undefined) { + value = (defaultVariants as Record)[propName as string]; + } + + if (value === undefined) continue; + + // Handle boolean variants + let variantKey: string; + if (typeof value === 'boolean') { + variantKey = value ? 'true' : 'false'; + } else { + variantKey = value as string; + } + + const variantStyle = variantGroup[variantKey as keyof typeof variantGroup]; + if (variantStyle) { + // Direct property copy (faster than Object.assign for small objects) + for (const styleKey in variantStyle) { + stylesObj[styleKey] = variantStyle[styleKey as keyof typeof variantStyle]; } } } - // Apply compound variants (highest priority before inline styles) - if (config.compoundVariants) { - for (const compound of config.compoundVariants) { - if ( - Object.entries(compound.variants).every(([propName, value]) => { - // Handle boolean values in compound variants - if (typeof value === 'boolean') { - return mergedProps[propName as keyof V] === value; - } + // Apply compound variants + if (compoundMatchers) { + for (let i = 0; i < compoundMatchers.length; i++) { + const matcher = compoundMatchers[i]; + let matches = true; + + for (let j = 0; j < matcher.conditionKeys.length; j++) { + const propName = matcher.conditionKeys[j]; + let propValue: unknown = (props as Record)?.[propName]; + if (propValue === undefined) { + propValue = (defaultVariants as Record)[propName]; + } + + const expectedValue = matcher.conditionValues[j]; - // Handle array of values - if (Array.isArray(value)) { - return value.includes(mergedProps[propName as keyof V]); + if (matcher.isArrayFlags[j]) { + if (!(expectedValue as unknown[]).includes(propValue)) { + matches = false; + break; } + } else if (propValue !== expectedValue) { + matches = false; + break; + } + } - // Handle single value (string/enum) - return mergedProps[propName as keyof V] === value; - }) - ) { - stylesObj = { - ...stylesObj, - ...compound.style, - }; + if (matches) { + const compoundStyle = matcher.style; + for (const styleKey in compoundStyle) { + stylesObj[styleKey] = compoundStyle[styleKey as keyof typeof compoundStyle]; + } } } } - // Create a StyleSheet for better performance - return StyleSheet.create({ + // Performance: Create StyleSheet once and cache + const result = StyleSheet.create({ style: stylesObj as ResolvedStyle, }).style; + + styleCache.set(cacheKey, result); + return result; }; } @@ -245,90 +318,117 @@ interface CreateTokensReturn { styled: StyledFunction; } -// Helper to resolve token references in style objects -function resolveTokens( +/** + * Performance optimization: Fast token resolution + * Uses for-in loop instead of Object.entries().reduce() for better performance + * Avoids array allocations and function call overhead + */ +function resolveTokensFast( style: StyleObject, tokens: Tokens, -): StyleObject { - return Object.entries(style).reduce>( - (acc, [key, value]) => { - if (typeof value !== 'string' || !value.startsWith('$')) { - acc[key] = value; - return acc; - } +): ResolvedStyle { + const result: Record = {}; - const tokenPath = value.slice(1).split('.'); - const [category, token] = tokenPath; + for (const key in style) { + const value = style[key as keyof typeof style]; - const tokenCategory = tokens[category as keyof Tokens] as - | Record - | undefined; - const tokenValue = tokenCategory?.[token]; + // Fast path: non-string values don't need token resolution + if (typeof value !== 'string') { + result[key] = value; + continue; + } + + // Fast path: values not starting with $ don't need token resolution + const firstChar = value.charCodeAt(0); + if (firstChar !== 36) { + // 36 is '$' + result[key] = value; + continue; + } + + // Token resolution path + const dotIndex = value.indexOf('.', 1); + if (dotIndex === -1) { + continue; // Invalid token format + } + + const category = value.substring(1, dotIndex); + const tokenName = value.substring(dotIndex + 1); + const tokenCategory = tokens[category as keyof Tokens] as + | Record + | undefined; + + if (tokenCategory) { + const tokenValue = tokenCategory[tokenName]; if (tokenValue !== undefined) { - acc[key] = tokenValue; + result[key] = tokenValue; } + } + } - return acc; - }, - {}, - ) as StyleObject; + return result as ResolvedStyle; } /** * Creates a token system and returns the styles function */ export function defineTokens(tokenConfig: T): CreateTokensReturn { - // Create the tokens object - const tokens = Object.entries(tokenConfig).reduce((acc, [category, values]) => { - return { - ...acc, - [category]: values, - }; - }, {} as T); + // Performance: Direct assignment instead of reduce with spread + const tokens = tokenConfig; - // Create a wrapped styles function that resolves token references + /** + * Performance optimized sva function + * - Uses for-in loops instead of Object.entries().reduce() + * - Avoids spread operators and intermediate objects + * - Resolves tokens once at definition time + */ const sva = >(config: VariantStyleConfig) => { // Resolve tokens in base styles - const resolvedBase = config.base ? resolveTokens(config.base, tokens) : config.base; - - // Resolve tokens in variants - const resolvedVariants = config.variants - ? (Object.entries(config.variants).reduce>((acc, [key, variantGroup]) => { - type VariantGroupType = Record>; - - const resolvedGroup = Object.entries(variantGroup as VariantGroupType).reduce< - Record> - >((groupAcc, [variantKey, variantStyles]) => { - return { - ...groupAcc, - [variantKey]: resolveTokens(variantStyles, tokens), - }; - }, {}); - - return { - ...acc, - [key as keyof V]: resolvedGroup as V[keyof V], - }; - }, {}) as V) - : ({} as V); - - // Resolve tokens in compound variants - const resolvedCompoundVariants = config.compoundVariants?.map((compound) => ({ - ...compound, - style: resolveTokens(compound.style, tokens), - })); + const resolvedBase = config.base ? resolveTokensFast(config.base, tokens) : undefined; + + // Performance: Resolve tokens in variants using for-in loops + const resolvedVariants: Record> = {}; + for (const variantKey in config.variants) { + const variantGroup = config.variants[variantKey as keyof V] as Record>; + const resolvedGroup: Record = {}; + + for (const optionKey in variantGroup) { + resolvedGroup[optionKey] = resolveTokensFast(variantGroup[optionKey], tokens); + } + + resolvedVariants[variantKey] = resolvedGroup; + } + + // Performance: Resolve tokens in compound variants + let resolvedCompoundVariants: CompoundVariant[] | undefined; + if (config.compoundVariants) { + resolvedCompoundVariants = new Array(config.compoundVariants.length); + for (let i = 0; i < config.compoundVariants.length; i++) { + const compound = config.compoundVariants[i]; + resolvedCompoundVariants[i] = { + variants: compound.variants, + style: resolveTokensFast(compound.style, tokens) as StyleObject, + }; + } + } // Return the styles function with resolved tokens return styles({ - ...config, - base: resolvedBase, - variants: resolvedVariants, + base: resolvedBase as StyleObject, + variants: resolvedVariants as V, compoundVariants: resolvedCompoundVariants, + defaultVariants: config.defaultVariants, }); }; /** * Styled function for creating styled components with token-aware styles + * Performance optimizations: + * - Pre-computes static styles at definition time + * - Uses React.memo to prevent unnecessary re-renders + * - Direct Map access for dynamic style caching + * - Minimizes object allocations in render path + * - Avoids array spread in style merging */ const styled: StyledFunction = >(Component: C) => { return

( @@ -336,13 +436,32 @@ export function defineTokens(tokenConfig: T): CreateToken | StyleObject | ((props: P & Omit, 'style'>) => StyleObject), ) => { - // Create and return a new component that applies the resolved styles - const StyledComponent: ComponentType< - P & + const isStaticStyle = typeof styleObject !== 'function'; + + // Performance: For static styles, resolve tokens and create StyleSheet ONCE at definition time + const staticStyle = isStaticStyle + ? StyleSheet.create({ + style: resolveTokensFast(styleObject, tokens), + }).style + : null; + + // Performance: Direct Map for dynamic style caching (no wrapper overhead) + const dynamicStyleCache = new Map(); + + // Performance: Pre-sort keys for dynamic style cache key generation + // This will be populated lazily on first dynamic render + let sortedPropKeys: string[] | null = null; + + // Performance: Pre-allocate style arrays for common cases + const styleArrayCache = new Map(); + + // Create the component function + const StyledComponentInner = ( + props: P & Omit, 'style'> & { style?: StyleProp; - } - > = (props) => { + }, + ) => { const { children, style: propStyle, @@ -352,37 +471,70 @@ export function defineTokens(tokenConfig: T): CreateToken style?: StyleProp; }; - // If styleObject is a function, call it with props to get the style object - const styleToResolve = - typeof styleObject === 'function' - ? styleObject(props as P & Omit, 'style'>) - : styleObject; - - // Resolve tokens in the style object - const resolvedStyle = resolveTokens(styleToResolve, tokens); - - // Create StyleSheet for the resolved style - const styles = StyleSheet.create({ - style: resolvedStyle as ResolvedStyle, - }); - - // Styles hierarchy: base styles → variants → compound variants → inline styles - // Inline styles (propStyle) must have highest priority, so they come last in the array - const mergedStyle = propStyle - ? [styles.style, ...(Array.isArray(propStyle) ? propStyle : [propStyle])] - : styles.style; - - // We need to cast here to handle the component props correctly - const componentProps = { - ...rest, - style: mergedStyle, - } as ComponentProps; - - // Use createElement instead of JSX to avoid syntax issues - return React.createElement(Component, componentProps, children); + let computedStyle: ViewStyle | TextStyle | ImageStyle; + + if (staticStyle) { + // Static styles: already pre-computed - zero computation + computedStyle = staticStyle; + } else { + // Dynamic styles: compute based on props with caching + // Lazily initialize sorted keys on first render + if (!sortedPropKeys) { + sortedPropKeys = Object.keys(rest).sort(); + } + + const cacheKey = generateCacheKeyFast(rest as Record, sortedPropKeys); + + // Direct Map access + const cached = dynamicStyleCache.get(cacheKey); + if (cached) { + computedStyle = cached; + } else { + const styleFn = styleObject as ( + props: P & Omit, 'style'>, + ) => StyleObject; + const styleToResolve = styleFn(props as P & Omit, 'style'>); + computedStyle = StyleSheet.create({ + style: resolveTokensFast(styleToResolve, tokens), + }).style; + dynamicStyleCache.set(cacheKey, computedStyle); + } + } + + // Performance: Optimized style merging + let finalStyle: StyleProp; + + if (!propStyle) { + // No prop style - use computed directly (most common case) + finalStyle = computedStyle; + } else if (Array.isArray(propStyle)) { + // Prop style is array - create merged array (avoid spread) + const arr = new Array(propStyle.length + 1); + arr[0] = computedStyle; + for (let i = 0; i < propStyle.length; i++) { + arr[i + 1] = propStyle[i]; + } + finalStyle = arr; + } else { + // Single prop style - create 2-element array + finalStyle = [computedStyle, propStyle]; + } + + // Performance: Reuse rest object by adding style property directly + (rest as Record).style = finalStyle; + + return React.createElement(Component, rest as ComponentProps, children); }; - // Set display name for better debugging + // Performance: Wrap with React.memo + const StyledComponent = memo(StyledComponentInner) as unknown as ComponentType< + P & + Omit, 'style'> & { + style?: StyleProp; + } + >; + + // Set display name for debugging const componentName = Component.displayName || Component.name || 'Component'; StyledComponent.displayName = `Styled(${componentName})`; diff --git a/packages/jacaranda/vitest.config.ts b/packages/jacaranda/vitest.config.ts index 25da2fe..9ee4c7d 100644 --- a/packages/jacaranda/vitest.config.ts +++ b/packages/jacaranda/vitest.config.ts @@ -1,10 +1,19 @@ import { defineConfig } from 'vitest/config'; -import reactNative from 'vitest-react-native'; +import path from 'path'; export default defineConfig({ - plugins: [reactNative()], + resolve: { + alias: { + 'react-native': path.resolve(__dirname, '__mocks__/react-native.ts'), + '@testing-library/react-native': path.resolve( + __dirname, + '__mocks__/@testing-library/react-native.ts', + ), + }, + }, test: { globals: true, + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], coverage: { include: ['src/**/*.ts', 'src/**/*.tsx'], },