From 9a4db9516d9ce9efef37a7b8eddc32d47b08df79 Mon Sep 17 00:00:00 2001 From: NikhilRW Date: Sun, 3 May 2026 00:13:31 +0530 Subject: [PATCH] feat: added unistyles support --- .../fixtures/unistyles-styles/code.js | 35 ++ .../fixtures/unistyles-styles/output.js | 49 +++ .../src/plugin/optimizers/text/index.ts | 7 + .../fixtures/unistyles-styles/code.js | 28 ++ .../fixtures/unistyles-styles/output.js | 33 ++ .../src/plugin/optimizers/view/index.ts | 18 +- .../src/plugin/utils/common/attributes.ts | 336 ++++++++++++++++++ .../com/timetorender/OnMarkerPaintedEvent.kt | 2 +- 8 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/unistyles-styles/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/unistyles-styles/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures/unistyles-styles/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures/unistyles-styles/output.js diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/unistyles-styles/code.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/unistyles-styles/code.js new file mode 100644 index 0000000..00fcb07 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/unistyles-styles/code.js @@ -0,0 +1,35 @@ +import { Text, StyleSheet as RNStyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import * as Unistyles from 'react-native-unistyles'; + +const styles = StyleSheet.create((theme) => ({ + title: { color: theme.colors.text }, + active: { fontWeight: '600' }, +})); + +const namespaceStyles = Unistyles.StyleSheet.create((theme) => ({ + title: { color: theme.colors.text }, +})); + +const staticStyles = StyleSheet.create({ + subtitle: { color: 'blue', fontSize: 16 }, +}); + +const rnStyles = RNStyleSheet.create({ + title: { color: 'red' }, +}); + +const titleStyle = styles.title; +const staticTitleStyle = staticStyles.subtitle; +const textProps = { style: titleStyle }; + +<> + Title + Alias + Active + Spread + Namespace + Static + Static Alias + React Native style +; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/unistyles-styles/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/unistyles-styles/output.js new file mode 100644 index 0000000..df74d4a --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures/unistyles-styles/output.js @@ -0,0 +1,49 @@ +import { processTextStyle as _processTextStyle, NativeText as _NativeText } from 'react-native-boost/runtime'; +import { Text, StyleSheet as RNStyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import * as Unistyles from 'react-native-unistyles'; +const styles = StyleSheet.create((theme) => ({ + title: { + color: theme.colors.text, + }, + active: { + fontWeight: '600', + }, +})); +const namespaceStyles = Unistyles.StyleSheet.create((theme) => ({ + title: { + color: theme.colors.text, + }, +})); +const staticStyles = StyleSheet.create({ + subtitle: { + color: 'blue', + fontSize: 16, + }, +}); +const rnStyles = RNStyleSheet.create({ + title: { + color: 'red', + }, +}); +const titleStyle = styles.title; +const staticTitleStyle = staticStyles.subtitle; +const textProps = { + style: titleStyle, +}; +<> + Title + Alias + Active + Spread + Namespace + <_NativeText {..._processTextStyle(staticStyles.subtitle)} allowFontScaling={true} ellipsizeMode={'tail'}> + Static + + <_NativeText {..._processTextStyle(staticTitleStyle)} allowFontScaling={true} ellipsizeMode={'tail'}> + Static Alias + + <_NativeText {..._processTextStyle(rnStyles.title)} allowFontScaling={true} ellipsizeMode={'tail'}> + React Native style + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/index.ts b/packages/react-native-boost/src/plugin/optimizers/text/index.ts index c0619f9..788c13c 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/text/index.ts @@ -8,6 +8,7 @@ import { buildPropertiesFromAttributes, hasAccessibilityProperty, hasBlacklistedProperty, + getUnistylesStyleStatus, isForcedLine, isIgnoredLine, isValidJSXComponent, @@ -44,6 +45,12 @@ export const textOptimizer: Optimizer = (path, logger) => { const parent = path.parent as t.JSXElement; const forced = isForcedLine(path); + const unistylesStyleStatus = getUnistylesStyleStatus(path); + + if (unistylesStyleStatus === 'dynamic') { + logger.skipped({ component: 'Text', path, reason: 'contains dynamic Unistyles styles' }); + return; + } const overridableChecks: BailoutCheck[] = [ { diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures/unistyles-styles/code.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures/unistyles-styles/code.js new file mode 100644 index 0000000..c892a80 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures/unistyles-styles/code.js @@ -0,0 +1,28 @@ +import { View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import * as Unistyles from 'react-native-unistyles'; + +const styles = StyleSheet.create((theme) => ({ + container: { backgroundColor: theme.colors.background }, +})); + +const namespaceStyles = Unistyles.StyleSheet.create((theme) => ({ + container: { backgroundColor: theme.colors.background }, +})); + +const staticStyles = StyleSheet.create({ + box: { backgroundColor: 'red', opacity: 1 }, +}); + +const containerStyle = styles.container; +const staticContainerStyle = staticStyles.box; +const viewProps = { style: containerStyle }; + +<> + + + + + + +; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures/unistyles-styles/output.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures/unistyles-styles/output.js new file mode 100644 index 0000000..592fd6e --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures/unistyles-styles/output.js @@ -0,0 +1,33 @@ +import { NativeView as _NativeView } from 'react-native-boost/runtime'; +import { View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import * as Unistyles from 'react-native-unistyles'; +const styles = StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.background, + }, +})); +const namespaceStyles = Unistyles.StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.background, + }, +})); +const staticStyles = StyleSheet.create({ + box: { + backgroundColor: 'red', + opacity: 1, + }, +}); +const containerStyle = styles.container; +const staticContainerStyle = staticStyles.box; +const viewProps = { + style: containerStyle, +}; +<> + + + + + <_NativeView style={staticStyles.box} /> + <_NativeView style={staticContainerStyle} /> +; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/index.ts b/packages/react-native-boost/src/plugin/optimizers/view/index.ts index 93b92f1..0ffbdb2 100644 --- a/packages/react-native-boost/src/plugin/optimizers/view/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/view/index.ts @@ -4,6 +4,7 @@ import PluginError from '../../utils/plugin-error'; import { BailoutCheck, getFirstBailoutReason } from '../../utils/helpers'; import { hasBlacklistedProperty, + getUnistylesStyleStatus, isForcedLine, isIgnoredLine, isValidJSXComponent, @@ -29,9 +30,20 @@ export const viewBlacklistedProperties = new Set([ 'style', // TODO: process style at runtime ]); +const viewBlacklistedPropertiesWithoutStyle = new Set( + [...viewBlacklistedProperties].filter((property) => property !== 'style') +); + export const viewOptimizer: Optimizer = (path, logger, options) => { if (!isValidJSXComponent(path, 'View')) return; if (!isReactNativeImport(path, 'View')) return; + const unistylesStyleStatus = getUnistylesStyleStatus(path); + const usesUnistylesStyle = unistylesStyleStatus === 'static'; + + if (unistylesStyleStatus === 'dynamic') { + logger.skipped({ component: 'View', path, reason: 'contains dynamic Unistyles styles' }); + return; + } let ancestorClassification: ViewAncestorClassification | undefined; const getAncestorClassification = () => { @@ -47,7 +59,11 @@ export const viewOptimizer: Optimizer = (path, logger, options) => { const overridableChecks: BailoutCheck[] = [ { reason: 'contains blacklisted props', - shouldBail: () => hasBlacklistedProperty(path, viewBlacklistedProperties), + shouldBail: () => + hasBlacklistedProperty( + path, + usesUnistylesStyle ? viewBlacklistedPropertiesWithoutStyle : viewBlacklistedProperties + ), }, { reason: 'has Text ancestor', diff --git a/packages/react-native-boost/src/plugin/utils/common/attributes.ts b/packages/react-native-boost/src/plugin/utils/common/attributes.ts index a34f2dc..798494c 100644 --- a/packages/react-native-boost/src/plugin/utils/common/attributes.ts +++ b/packages/react-native-boost/src/plugin/utils/common/attributes.ts @@ -2,6 +2,8 @@ import { NodePath, types as t } from '@babel/core'; import { ACCESSIBILITY_PROPERTIES } from '../constants'; import { USER_SELECT_STYLE_TO_SELECTABLE_PROP } from '../constants'; +const UNISTYLES_MODULE_NAME = 'react-native-unistyles'; + /** * Checks if the JSX element has a blacklisted property. * @@ -47,6 +49,340 @@ export const hasBlacklistedProperty = (path: NodePath, blac }); }; +export type UnistylesStyleStatus = 'none' | 'static' | 'dynamic'; + +/** + * Checks whether the JSX element receives a style that comes from Unistyles' + * StyleSheet.create output. + */ +export const hasUnistylesStyleProperty = (path: NodePath): boolean => { + return getUnistylesStyleStatus(path) !== 'none'; +}; + +/** + * Returns whether Unistyles styles used by this JSX element are provably static. + * Any runtime syntax in the StyleSheet.create call or style expression is treated + * as dynamic so the optimizer can leave the component unchanged. + */ +export const getUnistylesStyleStatus = (path: NodePath): UnistylesStyleStatus => { + let status: UnistylesStyleStatus = 'none'; + + for (const attribute of path.node.attributes) { + if (t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: 'style' })) { + const styleExpression = getJSXAttributeExpression(attribute); + status = mergeUnistylesStyleStatus( + status, + styleExpression ? getExpressionUnistylesStyleStatus(path, styleExpression) : 'none' + ); + } + + if (t.isJSXSpreadAttribute(attribute)) { + const objectExpression = resolveObjectExpression(path, attribute.argument); + if (!objectExpression) continue; + + for (const property of objectExpression.properties) { + if (!t.isObjectProperty(property) || property.computed || !isStaticPropertyName(property.key, 'style')) { + continue; + } + + status = mergeUnistylesStyleStatus( + status, + t.isExpression(property.value) ? getExpressionUnistylesStyleStatus(path, property.value) : 'none' + ); + } + } + + if (status === 'dynamic') return status; + } + + return status; +}; + +function mergeUnistylesStyleStatus(current: UnistylesStyleStatus, next: UnistylesStyleStatus): UnistylesStyleStatus { + if (current === 'dynamic' || next === 'dynamic') return 'dynamic'; + if (current === 'static' || next === 'static') return 'static'; + return 'none'; +} + +function getJSXAttributeExpression(attribute: t.JSXAttribute): t.Expression | undefined { + if (!attribute.value || !t.isJSXExpressionContainer(attribute.value)) return undefined; + if (t.isJSXEmptyExpression(attribute.value.expression)) return undefined; + + return attribute.value.expression; +} + +function resolveObjectExpression( + path: NodePath, + expression: t.Expression +): t.ObjectExpression | undefined { + if (t.isObjectExpression(expression)) return expression; + + if (t.isIdentifier(expression)) { + const binding = path.scope.getBinding(expression.name); + if ( + binding?.path.node && + t.isVariableDeclarator(binding.path.node) && + t.isObjectExpression(binding.path.node.init) + ) { + return binding.path.node.init; + } + } + + return undefined; +} + +function getExpressionUnistylesStyleStatus( + path: NodePath, + expression: t.Expression, + seen = new WeakSet() +): UnistylesStyleStatus { + if (seen.has(expression)) return 'none'; + seen.add(expression); + + if (t.isIdentifier(expression)) { + if (isUnistylesStyleSheetBinding(path, expression.name)) return 'dynamic'; + + const binding = path.scope.getBinding(expression.name); + if (!binding?.path.node || !t.isVariableDeclarator(binding.path.node)) return 'none'; + if (!binding.path.node.init || !t.isExpression(binding.path.node.init)) return 'none'; + + return getExpressionUnistylesStyleStatus(path, binding.path.node.init, seen); + } + + if (t.isMemberExpression(expression)) { + const memberStatus = getUnistylesStyleMemberStatus(path, expression); + if (memberStatus !== 'none') return memberStatus; + + let status: UnistylesStyleStatus = 'none'; + if (t.isExpression(expression.object)) { + status = mergeUnistylesStyleStatus(status, getExpressionUnistylesStyleStatus(path, expression.object, seen)); + } + if (expression.computed && t.isExpression(expression.property)) { + status = mergeUnistylesStyleStatus(status, getExpressionUnistylesStyleStatus(path, expression.property, seen)); + } + + return status; + } + + if (t.isArrayExpression(expression)) { + let status: UnistylesStyleStatus = 'none'; + + for (const element of expression.elements) { + if (!element) continue; + if (t.isSpreadElement(element)) { + return getExpressionUnistylesStyleStatus(path, element.argument, seen) === 'none' ? status : 'dynamic'; + } + if (t.isExpression(element)) { + status = mergeUnistylesStyleStatus(status, getExpressionUnistylesStyleStatus(path, element, seen)); + } + if (status === 'dynamic') return status; + } + + return status; + } + + if (t.isConditionalExpression(expression)) { + return mergeUnistylesStyleStatus( + getExpressionUnistylesStyleStatus(path, expression.test, seen), + mergeUnistylesStyleStatus( + getExpressionUnistylesStyleStatus(path, expression.consequent, seen), + getExpressionUnistylesStyleStatus(path, expression.alternate, seen) + ) + ) === 'none' + ? 'none' + : 'dynamic'; + } + + if (t.isLogicalExpression(expression) || t.isBinaryExpression(expression)) { + const status = mergeUnistylesStyleStatus( + t.isExpression(expression.left) ? getExpressionUnistylesStyleStatus(path, expression.left, seen) : 'none', + t.isExpression(expression.right) ? getExpressionUnistylesStyleStatus(path, expression.right, seen) : 'none' + ); + + return status === 'none' ? 'none' : 'dynamic'; + } + + if (t.isSequenceExpression(expression)) { + let status: UnistylesStyleStatus = 'none'; + + for (const item of expression.expressions) { + status = mergeUnistylesStyleStatus(status, getExpressionUnistylesStyleStatus(path, item, seen)); + if (status === 'dynamic') return status; + } + + return status; + } + + if (t.isCallExpression(expression)) { + let status: UnistylesStyleStatus = t.isExpression(expression.callee) + ? getExpressionUnistylesStyleStatus(path, expression.callee, seen) + : 'none'; + + for (const argument of expression.arguments) { + if (!t.isExpression(argument)) continue; + status = mergeUnistylesStyleStatus(status, getExpressionUnistylesStyleStatus(path, argument, seen)); + if (status !== 'none') return 'dynamic'; + } + + return status === 'none' ? 'none' : 'dynamic'; + } + + if (t.isUnaryExpression(expression)) { + return getExpressionUnistylesStyleStatus(path, expression.argument, seen); + } + + if (t.isTSAsExpression(expression) || t.isTSSatisfiesExpression(expression) || t.isTSNonNullExpression(expression)) { + return getExpressionUnistylesStyleStatus(path, expression.expression, seen); + } + + if (t.isTypeCastExpression(expression)) { + return getExpressionUnistylesStyleStatus(path, expression.expression, seen); + } + + return 'none'; +} + +function getUnistylesStyleMemberStatus( + path: NodePath, + expression: t.MemberExpression +): UnistylesStyleStatus { + if (!t.isIdentifier(expression.object)) return 'none'; + + const styleSheetCreateCall = getUnistylesStyleSheetCreateCallForBinding(path, expression.object.name); + if (!styleSheetCreateCall) return 'none'; + + const styleName = getStaticMemberName(expression); + if (!styleName) return 'dynamic'; + + const createArgument = styleSheetCreateCall.arguments[0]; + if (!t.isObjectExpression(createArgument)) return 'dynamic'; + + const styleProperty = findObjectProperty(createArgument, styleName); + if (!styleProperty || !t.isObjectProperty(styleProperty)) return 'dynamic'; + + return isStaticUnistylesStyleValue(styleProperty.value) ? 'static' : 'dynamic'; +} + +function getStaticMemberName(expression: t.MemberExpression): string | undefined { + if (!expression.computed && t.isIdentifier(expression.property)) return expression.property.name; + if (expression.computed && t.isStringLiteral(expression.property)) return expression.property.value; + return undefined; +} + +function findObjectProperty(objectExpression: t.ObjectExpression, name: string): t.ObjectProperty | undefined { + return objectExpression.properties.find((property): property is t.ObjectProperty => { + return t.isObjectProperty(property) && !property.computed && isStaticPropertyName(property.key, name); + }); +} + +function isStaticUnistylesStyleValue(node: t.Node): boolean { + if (t.isStringLiteral(node) || t.isNumericLiteral(node) || t.isBooleanLiteral(node) || t.isNullLiteral(node)) { + return true; + } + + if (t.isUnaryExpression(node)) { + return ['-', '+'].includes(node.operator) && t.isNumericLiteral(node.argument); + } + + if (t.isArrayExpression(node)) { + return node.elements.every( + (element) => element != null && t.isExpression(element) && isStaticUnistylesStyleValue(element) + ); + } + + if (t.isObjectExpression(node)) { + return node.properties.every((property) => { + return ( + t.isObjectProperty(property) && + !property.computed && + isStaticObjectKey(property.key) && + isStaticUnistylesStyleValue(property.value) + ); + }); + } + + if (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node) || t.isTSNonNullExpression(node)) { + return isStaticUnistylesStyleValue(node.expression); + } + + if (t.isTypeCastExpression(node)) { + return isStaticUnistylesStyleValue(node.expression); + } + + return false; +} + +function isStaticObjectKey(key: t.ObjectProperty['key']): boolean { + return t.isIdentifier(key) || t.isStringLiteral(key) || t.isNumericLiteral(key); +} + +function isUnistylesStyleSheetBinding(path: NodePath, name: string): boolean { + return getUnistylesStyleSheetCreateCallForBinding(path, name) !== undefined; +} + +function getUnistylesStyleSheetCreateCallForBinding( + path: NodePath, + name: string +): t.CallExpression | undefined { + const binding = path.scope.getBinding(name); + if (!binding?.path.node || !t.isVariableDeclarator(binding.path.node)) return undefined; + + const initializer = binding.path.node.init; + if (!t.isCallExpression(initializer)) return undefined; + if (!isUnistylesStyleSheetCreateCall(path, initializer)) return undefined; + + return initializer; +} + +function isUnistylesStyleSheetCreateCall(path: NodePath, expression: t.CallExpression): boolean { + const { callee } = expression; + if (!t.isMemberExpression(callee)) return false; + if (!t.isIdentifier(callee.property, { name: 'create' }) || callee.computed) return false; + + if (t.isIdentifier(callee.object)) { + return isUnistylesStyleSheetImport(path, callee.object.name); + } + + if ( + t.isMemberExpression(callee.object) && + t.isIdentifier(callee.object.object) && + t.isIdentifier(callee.object.property, { name: 'StyleSheet' }) && + !callee.object.computed + ) { + return isUnistylesNamespaceImport(path, callee.object.object.name); + } + + return false; +} +function isUnistylesStyleSheetImport(path: NodePath, name: string): boolean { + const binding = path.scope.getBinding(name); + if (!binding || binding.kind !== 'module') return false; + if (!t.isImportSpecifier(binding.path.node)) return false; + + const importDeclaration = binding.path.parent; + if (!t.isImportDeclaration(importDeclaration) || importDeclaration.source.value !== UNISTYLES_MODULE_NAME) { + return false; + } + + const imported = binding.path.node.imported; + return ( + t.isIdentifier(imported, { name: 'StyleSheet' }) || (t.isStringLiteral(imported) && imported.value === 'StyleSheet') + ); +} + +function isUnistylesNamespaceImport(path: NodePath, name: string): boolean { + const binding = path.scope.getBinding(name); + if (!binding || binding.kind !== 'module') return false; + if (!t.isImportNamespaceSpecifier(binding.path.node)) return false; + + const importDeclaration = binding.path.parent; + return t.isImportDeclaration(importDeclaration) && importDeclaration.source.value === UNISTYLES_MODULE_NAME; +} + +function isStaticPropertyName(key: t.ObjectProperty['key'], name: string): boolean { + return t.isIdentifier(key, { name }) || (t.isStringLiteral(key) && key.value === name); +} + /** * Adds a default property to a JSX element if it's not already defined. It avoids adding a default * if it cannot statically determine whether the property is already set. diff --git a/packages/react-native-time-to-render/android/src/main/java/com/timetorender/OnMarkerPaintedEvent.kt b/packages/react-native-time-to-render/android/src/main/java/com/timetorender/OnMarkerPaintedEvent.kt index 4ee1407..83785d2 100644 --- a/packages/react-native-time-to-render/android/src/main/java/com/timetorender/OnMarkerPaintedEvent.kt +++ b/packages/react-native-time-to-render/android/src/main/java/com/timetorender/OnMarkerPaintedEvent.kt @@ -15,7 +15,7 @@ internal class OnMarkerPaintedEvent(surfaceId: Int, viewId: Int, val paintTime: override protected fun getEventData(): WritableMap { val eventData: WritableMap = Arguments.createMap() eventData.putDouble("paintTime", paintTime.toDouble()) - eventData.putInt("target", getViewTag()) + eventData.putInt("target", viewTag) return eventData }