react-native-boost 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,16 @@
1
1
  import { NodePath, types as t } from '@babel/core';
2
- import { addNamed } from '@babel/helper-module-imports';
3
2
  import { HubFile, Optimizer } from '../../types';
4
3
  import PluginError from '../../utils/plugin-error';
5
- import { hasBlacklistedProperty, shouldIgnoreOptimization } from '../../utils/common';
4
+ import {
5
+ addFileImportHint,
6
+ buildPropertiesFromAttributes,
7
+ hasAccessibilityProperty,
8
+ hasBlacklistedProperty,
9
+ shouldIgnoreOptimization,
10
+ } from '../../utils/common';
6
11
 
7
12
  export const textBlacklistedProperties = new Set([
8
- 'accessible',
9
- 'accessibilityLabel',
10
- 'accessibilityState',
11
13
  'allowFontScaling',
12
- 'aria-busy',
13
- 'aria-checked',
14
- 'aria-disabled',
15
- 'aria-expanded',
16
- 'aria-label',
17
- 'aria-selected',
18
14
  'ellipsizeMode',
19
15
  'id',
20
16
  'nativeID',
@@ -78,16 +74,93 @@ export const textOptimizer: Optimizer = (path, log = () => {}) => {
78
74
 
79
75
  // Optimize props
80
76
  fixNegativeNumberOfLines({ path, log });
81
- optimizeStyleTag({ path, file });
82
77
 
83
- // Add TextNativeComponent import (cached on file) so we only add it once per file
84
- if (!file.__hasImports) {
85
- file.__hasImports = {};
78
+ // Process style and accessibility props
79
+ const originalAttributes = [...path.node.attributes];
80
+ let styleAttribute, styleExpr;
81
+ for (const attribute of originalAttributes) {
82
+ if (t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: 'style' })) {
83
+ styleAttribute = attribute;
84
+ break;
85
+ }
86
86
  }
87
- if (!file.__hasImports.NativeText) {
88
- file.__hasImports.NativeText = addNamed(path, 'NativeText', 'react-native/Libraries/Text/TextNativeComponent');
87
+ if (
88
+ styleAttribute &&
89
+ styleAttribute.value &&
90
+ t.isJSXExpressionContainer(styleAttribute.value) &&
91
+ !t.isJSXEmptyExpression(styleAttribute.value.expression)
92
+ ) {
93
+ styleExpr = styleAttribute.value.expression;
89
94
  }
90
- const nativeTextIdentifier = file.__hasImports.NativeText;
95
+ const hasA11y = hasAccessibilityProperty(path, originalAttributes);
96
+
97
+ if (styleExpr && hasA11y) {
98
+ // When both style and accessibility properties exist, we split them into two separate spread attributes
99
+
100
+ // Filter out the style attribute for accessibility props
101
+ const accessibilityAttributes = originalAttributes.filter((attribute) => {
102
+ if (t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: 'style' })) {
103
+ return false;
104
+ }
105
+ return true;
106
+ });
107
+
108
+ // Set up the accessibility import if needed
109
+ const normalizeIdentifier = addFileImportHint({
110
+ file,
111
+ nameHint: 'normalizeAccessibilityProps',
112
+ path,
113
+ importName: 'normalizeAccessibilityProps',
114
+ moduleName: 'react-native-boost',
115
+ });
116
+ const accessibilityObject = buildPropertiesFromAttributes(accessibilityAttributes);
117
+ const accessibilityExpr = t.callExpression(t.identifier(normalizeIdentifier.name), [accessibilityObject]);
118
+
119
+ // Set up the style import if needed.
120
+ const flattenIdentifier = addFileImportHint({
121
+ file,
122
+ nameHint: 'flattenTextStyle',
123
+ path,
124
+ importName: 'flattenTextStyle',
125
+ moduleName: 'react-native-boost',
126
+ });
127
+ const flattenedStyleExpr = t.callExpression(t.identifier(flattenIdentifier.name), [styleExpr]);
128
+
129
+ // Use two separate JSX spread attributes so that accessibility and style props remain distinct.
130
+ path.node.attributes = [t.jsxSpreadAttribute(accessibilityExpr), t.jsxSpreadAttribute(flattenedStyleExpr)];
131
+ } else if (styleExpr) {
132
+ // Only style attribute is present.
133
+ const flattenIdentifier = addFileImportHint({
134
+ file,
135
+ nameHint: 'flattenTextStyle',
136
+ path,
137
+ importName: 'flattenTextStyle',
138
+ moduleName: 'react-native-boost',
139
+ });
140
+ const flattened = t.callExpression(t.identifier(flattenIdentifier.name), [styleExpr]);
141
+ path.node.attributes = [t.jsxSpreadAttribute(flattened)];
142
+ } else if (hasA11y) {
143
+ // Only accessibility properties are present.
144
+ const normalizeIdentifier = addFileImportHint({
145
+ file,
146
+ nameHint: 'normalizeAccessibilityProps',
147
+ path,
148
+ importName: 'normalizeAccessibilityProps',
149
+ moduleName: 'react-native-boost',
150
+ });
151
+ const propsObject = buildPropertiesFromAttributes(originalAttributes);
152
+ const normalized = t.callExpression(t.identifier(normalizeIdentifier.name), [propsObject]);
153
+ path.node.attributes = [t.jsxSpreadAttribute(normalized)];
154
+ }
155
+
156
+ // Add TextNativeComponent import (cached on file) so we only add it once per file.
157
+ const nativeTextIdentifier = addFileImportHint({
158
+ file,
159
+ nameHint: 'NativeText',
160
+ path,
161
+ importName: 'NativeText',
162
+ moduleName: 'react-native/Libraries/Text/TextNativeComponent',
163
+ });
91
164
  path.node.name.name = nativeTextIdentifier.name;
92
165
 
93
166
  // If the element is not self-closing, update the closing element as well
@@ -106,17 +179,16 @@ function hasOnlyStringChildren(path: NodePath<t.JSXOpeningElement>, node: t.JSXE
106
179
  }
107
180
 
108
181
  function isStringNode(path: NodePath<t.JSXOpeningElement>, child: t.Node): boolean {
109
- if (t.isJSXText(child)) return true;
182
+ if (t.isJSXText(child) || t.isStringLiteral(child)) return true;
110
183
 
111
184
  // Check for JSX expressions
112
185
  if (t.isJSXExpressionContainer(child)) {
113
186
  const expression = child.expression;
114
-
115
- // If the expression is an identifier, look it up in the current scope
116
187
  if (t.isIdentifier(expression)) {
117
188
  const binding = path.scope.getBinding(expression.name);
118
189
  return binding ? t.isStringLiteral(binding.path.node) : false;
119
190
  }
191
+ if (t.isStringLiteral(expression)) return true;
120
192
  }
121
193
  return false;
122
194
  }
@@ -155,38 +227,19 @@ function fixNegativeNumberOfLines({
155
227
  }
156
228
  }
157
229
 
158
- function optimizeStyleTag({ path, file }: { path: NodePath<t.JSXOpeningElement>; file: HubFile }) {
159
- let shouldImportFlattenTextStyle = false;
160
- const nameHint = '_flattenTextStyle';
161
-
162
- for (const [index, attribute] of path.node.attributes.entries()) {
163
- if (t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: 'style' })) {
164
- shouldImportFlattenTextStyle = true;
165
-
166
- if (t.isJSXExpressionContainer(attribute.value) && !t.isJSXEmptyExpression(attribute.value.expression)) {
167
- path.node.attributes[index] = t.jsxSpreadAttribute(
168
- t.callExpression(t.identifier(nameHint), [attribute.value.expression])
169
- );
170
- }
171
- }
172
- }
173
-
174
- if (shouldImportFlattenTextStyle && !file.__hasImports?.flattenTextStyle) {
175
- if (!file.__hasImports) file.__hasImports = {};
176
- file.__hasImports.flattenTextStyle = addNamed(path, 'flattenTextStyle', 'react-native-boost', { nameHint });
177
- }
178
- }
179
-
180
230
  function hasInvalidChildren(path: NodePath<t.JSXOpeningElement>): boolean {
181
231
  for (const attribute of path.node.attributes) {
182
- if (t.isJSXSpreadAttribute(attribute)) return false; // spread attributes are handled in hasBlacklistedProperty
232
+ if (t.isJSXSpreadAttribute(attribute)) continue; // Spread attributes are handled in hasBlacklistedProperty
183
233
 
184
234
  if (t.isJSXIdentifier(attribute.name) && attribute.value) {
185
235
  // For a "children" attribute, optimization is allowed only if it is a string
186
236
  if (attribute.name.name === 'children') {
187
- return isStringNode(path, attribute.value);
237
+ if (!isStringNode(path, attribute.value)) {
238
+ return true;
239
+ }
240
+ } else if (textBlacklistedProperties.has(attribute.name.name)) {
241
+ return true;
188
242
  }
189
- return textBlacklistedProperties.has(attribute.name.name);
190
243
  }
191
244
  }
192
245
  return false;
@@ -1,8 +1,7 @@
1
1
  import { NodePath, types as t } from '@babel/core';
2
- import { addDefault } from '@babel/helper-module-imports';
3
2
  import { HubFile, Optimizer } from '../../types';
4
3
  import PluginError from '../../utils/plugin-error';
5
- import { hasBlacklistedProperty, shouldIgnoreOptimization } from '../../utils/common';
4
+ import { addFileImportHint, hasBlacklistedProperty, shouldIgnoreOptimization } from '../../utils/common';
6
5
 
7
6
  export const viewBlacklistedProperties = new Set([
8
7
  'accessible',
@@ -79,15 +78,14 @@ export const viewOptimizer: Optimizer = (path, log = () => {}) => {
79
78
  log(`Optimizing View component in ${filename}:${lineNumber}`);
80
79
 
81
80
  // Add ViewNativeComponent import (cached on the file) to prevent duplicate imports.
82
- if (!file.__hasImports) {
83
- file.__hasImports = {};
84
- }
85
- if (!file.__hasImports.ViewNativeComponent) {
86
- file.__hasImports.NativeView = addDefault(path, 'react-native/Libraries/Components/View/ViewNativeComponent', {
87
- nameHint: 'NativeView',
88
- });
89
- }
90
- const viewNativeIdentifier = file.__hasImports.NativeView;
81
+ const viewNativeIdentifier = addFileImportHint({
82
+ file,
83
+ path,
84
+ importName: 'ViewNativeComponent',
85
+ moduleName: 'react-native/Libraries/Components/View/ViewNativeComponent',
86
+ importType: 'default',
87
+ nameHint: 'NativeView',
88
+ });
91
89
 
92
90
  // Replace the component with its native counterpart.
93
91
  path.node.name.name = viewNativeIdentifier.name;
@@ -36,3 +36,26 @@ export type HubFile = t.File & {
36
36
  __hasImports?: Record<string, t.Identifier>;
37
37
  __optimized?: boolean;
38
38
  };
39
+
40
+ /**
41
+ * Options for adding a file import hint.
42
+ */
43
+ export interface FileImportOptions {
44
+ file: HubFile;
45
+ /** The name hint which also acts as the cache key to ensure the import is only added once (e.g. 'normalizeAccessibilityProps') */
46
+ nameHint: string;
47
+ /** The current Babel NodePath */
48
+ path: NodePath;
49
+ /**
50
+ * The named import string (e.g. 'normalizeAccessibilityProps'). Ignored if importType is "default".
51
+ */
52
+ importName: string;
53
+ /** The module to import from (e.g. 'react-native-boost') */
54
+ moduleName: string;
55
+ /**
56
+ * Determines which helper to use:
57
+ * - "named" (default) uses addNamed (requires importName)
58
+ * - "default" uses addDefault
59
+ */
60
+ importType?: 'named' | 'default';
61
+ }
@@ -1,10 +1,41 @@
1
1
  import { NodePath, types as t } from '@babel/core';
2
2
  import { ensureArray } from './helpers';
3
- import { HubFile } from '../types';
3
+ import { FileImportOptions, HubFile } from '../types';
4
4
  import { minimatch } from 'minimatch';
5
5
  import path from 'node:path';
6
6
  import PluginError from './plugin-error';
7
+ import { addDefault, addNamed } from '@babel/helper-module-imports';
7
8
 
9
+ /**
10
+ * Adds a hint to the file object to ensure that a specific import is added only once and cached on the file object.
11
+ *
12
+ * @param opts - Object containing the function arguments:
13
+ * - file: The Babel file object (e.g. HubFile)
14
+ * - nameHint: The name hint which also acts as the cache key to ensure the import is only added once (e.g. 'normalizeAccessibilityProps')
15
+ * - path: The current Babel NodePath
16
+ * - importName: The named import string (e.g. 'normalizeAccessibilityProps'), used when importType is 'named'
17
+ * - moduleName: The module to import from (e.g. 'react-native-boost')
18
+ * - importType: Either 'named' (default) or 'default' to determine the type of import to use.
19
+ *
20
+ * @returns The identifier returned by addNamed or addDefault.
21
+ */
22
+ export function addFileImportHint({
23
+ file,
24
+ nameHint,
25
+ path,
26
+ importName,
27
+ moduleName,
28
+ importType = 'named',
29
+ }: FileImportOptions): t.Identifier {
30
+ if (!file.__hasImports?.[nameHint]) {
31
+ file.__hasImports = file.__hasImports || {};
32
+ file.__hasImports[nameHint] =
33
+ importType === 'default'
34
+ ? addDefault(path, moduleName, { nameHint })
35
+ : addNamed(path, importName, moduleName, { nameHint });
36
+ }
37
+ return file.__hasImports[nameHint];
38
+ }
8
39
  /**
9
40
  * Checks if the file is in the list of ignored files.
10
41
  *
@@ -44,6 +75,9 @@ export const isIgnoredFile = (p: NodePath<t.JSXOpeningElement>, ignores: string[
44
75
  *
45
76
  * The function looks up the JSXOpeningElement's own leading comments as well as
46
77
  * the parent element's comments before falling back to inspect siblings.
78
+ *
79
+ * @param path - The path to the JSXOpeningElement.
80
+ * @returns true if the JSX element should be ignored.
47
81
  */
48
82
  export const shouldIgnoreOptimization = (path: NodePath<t.JSXOpeningElement>): boolean => {
49
83
  // Check for @boost-ignore in the leading comments on the JSX opening element.
@@ -109,6 +143,13 @@ export const shouldIgnoreOptimization = (path: NodePath<t.JSXOpeningElement>): b
109
143
  return false;
110
144
  };
111
145
 
146
+ /**
147
+ * Checks if the JSX element has a blacklisted property.
148
+ *
149
+ * @param path - The path to the JSXOpeningElement.
150
+ * @param blacklist - The set of blacklisted properties.
151
+ * @returns true if the JSX element has a blacklisted property.
152
+ */
112
153
  export const hasBlacklistedProperty = (path: NodePath<t.JSXOpeningElement>, blacklist: Set<string>): boolean => {
113
154
  return path.node.attributes.some((attribute) => {
114
155
  // Check if we can resolve the spread attribute
@@ -141,3 +182,115 @@ export const hasBlacklistedProperty = (path: NodePath<t.JSXOpeningElement>, blac
141
182
  return false;
142
183
  });
143
184
  };
185
+
186
+ /**
187
+ * Helper that builds an Object.assign expression out of the existing JSX attributes.
188
+ * It handles both plain JSXAttributes and spread attributes.
189
+ *
190
+ * @param attributes - The attributes to build the expression from.
191
+ * @returns The Object.assign expression.
192
+ */
193
+ export const buildPropertiesFromAttributes = (attributes: (t.JSXAttribute | t.JSXSpreadAttribute)[]): t.Expression => {
194
+ const arguments_: t.Expression[] = [];
195
+ for (const attribute of attributes) {
196
+ if (t.isJSXSpreadAttribute(attribute)) {
197
+ arguments_.push(attribute.argument);
198
+ } else if (t.isJSXAttribute(attribute)) {
199
+ const key = attribute.name.name;
200
+ let value: t.Expression;
201
+ if (!attribute.value) {
202
+ value = t.booleanLiteral(true);
203
+ } else if (t.isStringLiteral(attribute.value)) {
204
+ value = attribute.value;
205
+ } else if (t.isJSXExpressionContainer(attribute.value)) {
206
+ value = t.isJSXEmptyExpression(attribute.value.expression)
207
+ ? t.booleanLiteral(true)
208
+ : attribute.value.expression;
209
+ } else {
210
+ value = t.nullLiteral();
211
+ }
212
+ // If the key is not a valid JavaScript identifier (e.g. "aria-label"), use a string literal.
213
+ const validIdentifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
214
+ const keyNode =
215
+ typeof key === 'string' && validIdentifierRegex.test(key) ? t.identifier(key) : t.stringLiteral(key.toString());
216
+
217
+ arguments_.push(t.objectExpression([t.objectProperty(keyNode, value)]));
218
+ }
219
+ }
220
+ if (arguments_.length === 0) {
221
+ return t.objectExpression([]);
222
+ }
223
+ return t.callExpression(t.memberExpression(t.identifier('Object'), t.identifier('assign')), [
224
+ t.objectExpression([]),
225
+ ...arguments_,
226
+ ]);
227
+ };
228
+
229
+ /**
230
+ * The set of accessibility properties that need to be normalized.
231
+ */
232
+ export const accessibilityProperties = new Set([
233
+ 'accessibilityLabel',
234
+ 'aria-label',
235
+ 'accessibilityState',
236
+ 'aria-busy',
237
+ 'aria-checked',
238
+ 'aria-disabled',
239
+ 'aria-expanded',
240
+ 'aria-selected',
241
+ 'accessible',
242
+ ]);
243
+
244
+ /**
245
+ * Checks if the JSX element has an accessibility property.
246
+ *
247
+ * @param path - The NodePath for the JSXOpeningElement, used for scope lookup.
248
+ * @param attributes - The attributes to check.
249
+ * @returns true if the JSX element has an accessibility property.
250
+ */
251
+ export const hasAccessibilityProperty = (
252
+ path: NodePath<t.JSXOpeningElement>,
253
+ attributes: (t.JSXAttribute | t.JSXSpreadAttribute)[]
254
+ ): boolean => {
255
+ for (const attribute of attributes) {
256
+ if (t.isJSXAttribute(attribute)) {
257
+ const key = attribute.name.name;
258
+ if (typeof key === 'string' && accessibilityProperties.has(key)) {
259
+ return true;
260
+ }
261
+ } else if (t.isJSXSpreadAttribute(attribute)) {
262
+ if (t.isObjectExpression(attribute.argument)) {
263
+ for (const property of attribute.argument.properties) {
264
+ if (
265
+ t.isObjectProperty(property) &&
266
+ t.isIdentifier(property.key) &&
267
+ accessibilityProperties.has(property.key.name)
268
+ ) {
269
+ return true;
270
+ }
271
+ }
272
+ } else if (t.isIdentifier(attribute.argument)) {
273
+ const binding = path.scope.getBinding(attribute.argument.name);
274
+ if (binding && t.isVariableDeclarator(binding.path.node)) {
275
+ const declarator = binding.path.node as t.VariableDeclarator;
276
+ if (declarator.init && t.isObjectExpression(declarator.init)) {
277
+ for (const property of declarator.init.properties) {
278
+ if (
279
+ t.isObjectProperty(property) &&
280
+ t.isIdentifier(property.key) &&
281
+ accessibilityProperties.has(property.key.name)
282
+ ) {
283
+ return true;
284
+ }
285
+ }
286
+ continue;
287
+ }
288
+ }
289
+ return true;
290
+ } else {
291
+ return true;
292
+ }
293
+ }
294
+ }
295
+ return false;
296
+ };
@@ -55,4 +55,60 @@ export const verticalAlignToTextAlignVerticalMap = {
55
55
  middle: 'center',
56
56
  };
57
57
 
58
+ /**
59
+ * Normalizes accessibility props.
60
+ *
61
+ * @param props - The props to normalize.
62
+ * @returns The normalized props.
63
+ */
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ export function normalizeAccessibilityProperties(props: Record<string, any>): Record<string, any> {
66
+ const {
67
+ accessibilityLabel,
68
+ ['aria-label']: ariaLabel,
69
+ accessibilityState,
70
+ ['aria-busy']: ariaBusy,
71
+ ['aria-checked']: ariaChecked,
72
+ ['aria-disabled']: ariaDisabled,
73
+ ['aria-expanded']: ariaExpanded,
74
+ ['aria-selected']: ariaSelected,
75
+ accessible,
76
+ ...restProperties
77
+ } = props;
78
+
79
+ // Merge label props: prefer the aria-label if defined.
80
+ const normalizedLabel = ariaLabel ?? accessibilityLabel;
81
+
82
+ // Merge the accessibilityState with any provided ARIA properties.
83
+ let normalizedState = accessibilityState;
84
+ if (ariaBusy != null || ariaChecked != null || ariaDisabled != null || ariaExpanded != null || ariaSelected != null) {
85
+ normalizedState =
86
+ normalizedState == null
87
+ ? {
88
+ busy: ariaBusy,
89
+ checked: ariaChecked,
90
+ disabled: ariaDisabled,
91
+ expanded: ariaExpanded,
92
+ selected: ariaSelected,
93
+ }
94
+ : {
95
+ busy: ariaBusy ?? normalizedState.busy,
96
+ checked: ariaChecked ?? normalizedState.checked,
97
+ disabled: ariaDisabled ?? normalizedState.disabled,
98
+ expanded: ariaExpanded ?? normalizedState.expanded,
99
+ selected: ariaSelected ?? normalizedState.selected,
100
+ };
101
+ }
102
+
103
+ // For the accessible prop, if not provided, default to `true`
104
+ const normalizedAccessible = accessible == null ? true : accessible;
105
+
106
+ return {
107
+ ...restProperties,
108
+ accessibilityLabel: normalizedLabel,
109
+ accessibilityState: normalizedState,
110
+ accessible: normalizedAccessible,
111
+ };
112
+ }
113
+
58
114
  export * from './types';