react-native-boost 1.0.0 → 1.2.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.
- package/README.md +16 -4
- package/dist/plugin/esm/index.mjs +167 -92
- package/dist/plugin/esm/index.mjs.map +1 -1
- package/dist/plugin/index.d.ts +10 -0
- package/dist/plugin/index.js +167 -92
- package/dist/plugin/index.js.map +1 -1
- package/dist/runtime/esm/index.mjs +16 -4
- package/dist/runtime/esm/index.mjs.map +1 -1
- package/dist/runtime/esm/index.web.mjs +2 -1
- package/dist/runtime/esm/index.web.mjs.map +1 -1
- package/dist/runtime/index.d.ts +16 -3
- package/dist/runtime/index.js +15 -2
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/index.web.d.ts +2 -1
- package/dist/runtime/index.web.js +2 -0
- package/dist/runtime/index.web.js.map +1 -1
- package/package.json +6 -1
- package/src/plugin/index.ts +5 -1
- package/src/plugin/optimizers/text/index.ts +118 -50
- package/src/plugin/optimizers/view/index.ts +37 -41
- package/src/plugin/types/index.ts +18 -1
- package/src/plugin/utils/common/attributes.ts +71 -12
- package/src/plugin/utils/common/validation.ts +52 -32
- package/src/plugin/utils/logger.ts +16 -2
- package/src/runtime/index.ts +39 -5
- package/src/runtime/index.web.ts +5 -0
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import { types as t } from '@babel/core';
|
|
2
2
|
import { HubFile, Optimizer } from '../../types';
|
|
3
3
|
import PluginError from '../../utils/plugin-error';
|
|
4
|
-
import { getFirstBailoutReason } from '../../utils/helpers';
|
|
4
|
+
import { BailoutCheck, getFirstBailoutReason } from '../../utils/helpers';
|
|
5
5
|
import {
|
|
6
6
|
hasBlacklistedProperty,
|
|
7
|
+
isForcedLine,
|
|
7
8
|
isIgnoredLine,
|
|
8
9
|
isValidJSXComponent,
|
|
9
10
|
isReactNativeImport,
|
|
10
11
|
replaceWithNativeComponent,
|
|
11
|
-
|
|
12
|
-
ViewAncestorClassification,
|
|
12
|
+
ancestorBailoutChecks,
|
|
13
13
|
} from '../../utils/common';
|
|
14
14
|
|
|
15
15
|
export const viewBlacklistedProperties = new Set([
|
|
16
|
-
//
|
|
16
|
+
// The `View` wrapper translates these into native props (e.g. `aria-*` → `accessibility*`,
|
|
17
|
+
// `tabIndex` → `focusable`). The native host does not understand them, so passing them through
|
|
18
|
+
// would silently drop them. TODO: process these at runtime instead of bailing.
|
|
17
19
|
'accessible',
|
|
18
20
|
'accessibilityLabel',
|
|
19
21
|
'accessibilityState',
|
|
@@ -21,60 +23,55 @@ export const viewBlacklistedProperties = new Set([
|
|
|
21
23
|
'aria-checked',
|
|
22
24
|
'aria-disabled',
|
|
23
25
|
'aria-expanded',
|
|
26
|
+
'aria-hidden',
|
|
24
27
|
'aria-label',
|
|
28
|
+
'aria-labelledby',
|
|
29
|
+
'aria-live',
|
|
25
30
|
'aria-selected',
|
|
31
|
+
'aria-valuemax',
|
|
32
|
+
'aria-valuemin',
|
|
33
|
+
'aria-valuenow',
|
|
34
|
+
'aria-valuetext',
|
|
26
35
|
'id',
|
|
27
36
|
'nativeID',
|
|
28
|
-
'
|
|
37
|
+
'tabIndex',
|
|
29
38
|
]);
|
|
30
39
|
|
|
31
40
|
export const viewOptimizer: Optimizer = (path, logger, options) => {
|
|
32
41
|
if (!isValidJSXComponent(path, 'View')) return;
|
|
42
|
+
if (!isReactNativeImport(path, 'View')) return;
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
const getAncestorClassification = () => {
|
|
36
|
-
if (!ancestorClassification) {
|
|
37
|
-
ancestorClassification = getViewAncestorClassification(path);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return ancestorClassification;
|
|
41
|
-
};
|
|
44
|
+
const forced = isForcedLine(path);
|
|
42
45
|
|
|
43
|
-
const
|
|
44
|
-
{
|
|
45
|
-
reason: 'line is marked with @boost-ignore',
|
|
46
|
-
shouldBail: () => isIgnoredLine(path),
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
reason: 'View is not imported from react-native',
|
|
50
|
-
shouldBail: () => !isReactNativeImport(path, 'View'),
|
|
51
|
-
},
|
|
46
|
+
const overridableChecks: BailoutCheck[] = [
|
|
52
47
|
{
|
|
53
48
|
reason: 'contains blacklisted props',
|
|
54
49
|
shouldBail: () => hasBlacklistedProperty(path, viewBlacklistedProperties),
|
|
55
50
|
},
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
shouldBail: () => getAncestorClassification() === 'text',
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
reason: 'has unresolved ancestor and dangerous optimization is disabled',
|
|
62
|
-
shouldBail: () =>
|
|
63
|
-
getAncestorClassification() === 'unknown' && options?.dangerouslyOptimizeViewWithUnknownAncestors !== true,
|
|
64
|
-
},
|
|
65
|
-
]);
|
|
51
|
+
...ancestorBailoutChecks(path, options?.dangerouslyOptimizeViewWithUnknownAncestors === true),
|
|
52
|
+
];
|
|
66
53
|
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
component: 'View',
|
|
70
|
-
path,
|
|
71
|
-
reason: skipReason,
|
|
72
|
-
});
|
|
54
|
+
if (forced) {
|
|
55
|
+
const overriddenReason = getFirstBailoutReason(overridableChecks);
|
|
73
56
|
|
|
74
|
-
|
|
57
|
+
if (overriddenReason) {
|
|
58
|
+
logger.forced({ component: 'View', path, reason: overriddenReason });
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
const skipReason = getFirstBailoutReason([
|
|
62
|
+
{
|
|
63
|
+
reason: 'line is marked with @boost-ignore',
|
|
64
|
+
shouldBail: () => isIgnoredLine(path),
|
|
65
|
+
},
|
|
66
|
+
...overridableChecks,
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
if (skipReason) {
|
|
70
|
+
logger.skipped({ component: 'View', path, reason: skipReason });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
75
73
|
}
|
|
76
74
|
|
|
77
|
-
// Extract the file from the Babel hub
|
|
78
75
|
const hub = path.hub as unknown;
|
|
79
76
|
const file = typeof hub === 'object' && hub !== null && 'file' in hub ? (hub.file as HubFile) : undefined;
|
|
80
77
|
|
|
@@ -89,6 +86,5 @@ export const viewOptimizer: Optimizer = (path, logger, options) => {
|
|
|
89
86
|
|
|
90
87
|
const parent = path.parent as t.JSXElement;
|
|
91
88
|
|
|
92
|
-
// Replace the View component with NativeView
|
|
93
89
|
replaceWithNativeComponent(path, parent, file, 'NativeView');
|
|
94
90
|
};
|
|
@@ -52,6 +52,16 @@ export interface PluginOptions {
|
|
|
52
52
|
* @default false
|
|
53
53
|
*/
|
|
54
54
|
dangerouslyOptimizeViewWithUnknownAncestors?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Opt-in flag that allows Text optimization when ancestor components cannot be statically resolved.
|
|
57
|
+
*
|
|
58
|
+
* This increases optimization coverage, but may introduce behavioral differences when an unresolved
|
|
59
|
+
* ancestor renders a React Native `Text` wrapper: a nested `Text` must render as the inline
|
|
60
|
+
* `NativeVirtualText` host rather than `NativeText`, and optimizing it would emit the wrong host.
|
|
61
|
+
* Prefer targeted `@boost-force` first, and enable this only after verifying affected screens.
|
|
62
|
+
* @default false
|
|
63
|
+
*/
|
|
64
|
+
dangerouslyOptimizeTextWithUnknownAncestors?: boolean;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
export type OptimizableComponent = 'Text' | 'View';
|
|
@@ -74,10 +84,17 @@ export interface WarningLogPayload {
|
|
|
74
84
|
export interface PluginLogger {
|
|
75
85
|
optimized: (payload: OptimizationLogPayload) => void;
|
|
76
86
|
skipped: (payload: SkippedOptimizationLogPayload) => void;
|
|
87
|
+
forced: (payload: SkippedOptimizationLogPayload) => void;
|
|
77
88
|
warning: (payload: WarningLogPayload) => void;
|
|
78
89
|
}
|
|
79
90
|
|
|
80
|
-
export type Optimizer = (
|
|
91
|
+
export type Optimizer = (
|
|
92
|
+
path: NodePath<t.JSXOpeningElement>,
|
|
93
|
+
logger: PluginLogger,
|
|
94
|
+
options?: PluginOptions,
|
|
95
|
+
/** Target platform from Babel's caller (e.g. Metro sets `'ios'`/`'android'`). Lets optimizers resolve platform-specific defaults at build time. */
|
|
96
|
+
platform?: string
|
|
97
|
+
) => void;
|
|
81
98
|
|
|
82
99
|
export type HubFile = t.File & {
|
|
83
100
|
opts: {
|
|
@@ -289,21 +289,80 @@ export function extractSelectableAndUpdateStyle(styleExpr: t.Expression): boolea
|
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
/**
|
|
292
|
-
*
|
|
292
|
+
* Determines whether an expression is statically provable to evaluate to a `string` or `number`
|
|
293
|
+
* primitive — and therefore can never be a React element.
|
|
293
294
|
*/
|
|
294
|
-
|
|
295
|
-
|
|
295
|
+
const isPrimitiveExpression = (
|
|
296
|
+
path: NodePath<t.JSXOpeningElement>,
|
|
297
|
+
expression: t.Expression,
|
|
298
|
+
// Identifier names already resolved along this chain, to break circular `const` references
|
|
299
|
+
// (`const a = b; const b = a;`) that would otherwise recurse forever.
|
|
300
|
+
resolved: Set<string> = new Set()
|
|
301
|
+
): boolean => {
|
|
302
|
+
// Unambiguous primitives — these can never be a React element.
|
|
303
|
+
if (t.isStringLiteral(expression) || t.isNumericLiteral(expression)) return true;
|
|
296
304
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
+
// A template literal ALWAYS coerces its interpolations to string, regardless of their types.
|
|
306
|
+
if (t.isTemplateLiteral(expression)) return true;
|
|
307
|
+
|
|
308
|
+
// `a + b` (and numeric `-`, `*`, ...) is primitive only when BOTH operands are themselves
|
|
309
|
+
// provably primitive. `in`/`instanceof` have a non-Expression `left` (a `PrivateName`), so the
|
|
310
|
+
// `isExpression` guard rejects them.
|
|
311
|
+
if (t.isBinaryExpression(expression)) {
|
|
312
|
+
const { left, right } = expression;
|
|
313
|
+
return (
|
|
314
|
+
t.isExpression(left) &&
|
|
315
|
+
isPrimitiveExpression(path, left, resolved) &&
|
|
316
|
+
isPrimitiveExpression(path, right, resolved)
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// `c ? a : b` — only the reachable results (`a`, `b`) are rendered; the test is irrelevant.
|
|
321
|
+
if (t.isConditionalExpression(expression)) {
|
|
322
|
+
return (
|
|
323
|
+
isPrimitiveExpression(path, expression.consequent, resolved) &&
|
|
324
|
+
isPrimitiveExpression(path, expression.alternate, resolved)
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// `a && b` / `a || b` / `a ?? b` — short-circuiting makes the result one of the operands, so both
|
|
329
|
+
// must be primitive (unlike the conditional, the left operand is itself a reachable result).
|
|
330
|
+
if (t.isLogicalExpression(expression)) {
|
|
331
|
+
return (
|
|
332
|
+
isPrimitiveExpression(path, expression.left, resolved) && isPrimitiveExpression(path, expression.right, resolved)
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Identifier resolving to a non-reassigned `const` whose initializer is itself provably primitive.
|
|
337
|
+
// `binding.constant` excludes reassigned bindings (`let x = 'a'; x = <Foo/>`).
|
|
338
|
+
if (t.isIdentifier(expression)) {
|
|
339
|
+
if (resolved.has(expression.name)) return false; // circular reference — give up
|
|
340
|
+
const binding = path.scope.getBinding(expression.name);
|
|
341
|
+
if (binding && binding.constant && binding.path.node && t.isVariableDeclarator(binding.path.node)) {
|
|
342
|
+
const init = binding.path.node.init;
|
|
343
|
+
return (
|
|
344
|
+
!!init && t.isExpression(init) && isPrimitiveExpression(path, init, new Set(resolved).add(expression.name))
|
|
345
|
+
);
|
|
305
346
|
}
|
|
306
|
-
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return false;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Checks whether a Text child node is statically provable to render as a string/number primitive.
|
|
355
|
+
* Used to gate the Text optimizer: only such children are safe to keep when rewriting `<Text>` to
|
|
356
|
+
* its native host. See {@link isPrimitiveExpression} for the underlying expression rules.
|
|
357
|
+
*/
|
|
358
|
+
export const isPrimitiveChild = (path: NodePath<t.JSXOpeningElement>, child: t.Node): boolean => {
|
|
359
|
+
if (t.isJSXText(child)) return true; // raw text between tags
|
|
360
|
+
if (t.isStringLiteral(child)) return true; // explicit `children="..."` attribute value
|
|
361
|
+
|
|
362
|
+
if (t.isJSXExpressionContainer(child)) {
|
|
363
|
+
const { expression } = child;
|
|
364
|
+
if (t.isJSXEmptyExpression(expression)) return false; // `{/* comment */}` is not a primitive
|
|
365
|
+
return isPrimitiveExpression(path, expression);
|
|
307
366
|
}
|
|
308
367
|
return false;
|
|
309
368
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NodePath, types as t } from '@babel/core';
|
|
2
|
-
import { ensureArray } from '../helpers';
|
|
2
|
+
import { ensureArray, BailoutCheck } from '../helpers';
|
|
3
3
|
import { HubFile } from '../../types';
|
|
4
4
|
import { minimatch } from 'minimatch';
|
|
5
5
|
import nodePath from 'node:path';
|
|
@@ -39,54 +39,52 @@ export const isIgnoredFile = (path: NodePath<t.JSXOpeningElement>, ignores: stri
|
|
|
39
39
|
return false;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
export const isForcedLine = (path: NodePath<t.JSXOpeningElement>): boolean => {
|
|
43
|
+
return hasDecoratorComment(path, '@boost-force');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const isIgnoredLine = (path: NodePath<t.JSXOpeningElement>): boolean => {
|
|
47
|
+
return hasDecoratorComment(path, '@boost-ignore');
|
|
48
|
+
};
|
|
49
|
+
|
|
42
50
|
/**
|
|
43
|
-
* Checks if the JSX element
|
|
44
|
-
*
|
|
45
|
-
* The function looks up the JSXOpeningElement's own leading comments as well as
|
|
46
|
-
* the parent element's comments before falling back to inspect siblings.
|
|
51
|
+
* Checks if the JSX element has a preceding comment containing the given decorator string.
|
|
47
52
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
53
|
+
* Scans the JSXOpeningElement's own leading comments, the parent element's comments,
|
|
54
|
+
* ObjectProperty containers, and backward siblings.
|
|
50
55
|
*/
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (path.node.leadingComments?.some((comment) => comment.value.includes('@boost-ignore'))) {
|
|
56
|
+
function hasDecoratorComment(path: NodePath<t.JSXOpeningElement>, decorator: string): boolean {
|
|
57
|
+
if (path.node.leadingComments?.some((comment) => comment.value.includes(decorator))) {
|
|
54
58
|
return true;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
// Check for @boost-ignore in the leading comments on the parent JSX element.
|
|
58
61
|
const jsxElementPath = path.parentPath;
|
|
59
|
-
if (jsxElementPath.node.leadingComments?.some((comment) => comment.value.includes(
|
|
62
|
+
if (jsxElementPath.node.leadingComments?.some((comment) => comment.value.includes(decorator))) {
|
|
60
63
|
return true;
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
//
|
|
64
|
-
// This handles cases where the JSX element is used as a value inside an object literal.
|
|
66
|
+
// Check leading comments on the ObjectProperty (if the JSX element is a value inside an object literal).
|
|
65
67
|
const propertyPath = jsxElementPath.parentPath;
|
|
66
68
|
if (
|
|
67
69
|
propertyPath &&
|
|
68
70
|
propertyPath.isObjectProperty() &&
|
|
69
|
-
propertyPath.node.leadingComments?.some((comment) => comment.value.includes(
|
|
71
|
+
propertyPath.node.leadingComments?.some((comment) => comment.value.includes(decorator))
|
|
70
72
|
) {
|
|
71
73
|
return true;
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
if (!jsxElementPath.parentPath) return false;
|
|
75
77
|
|
|
76
|
-
// Get the container that holds this element (for example, a JSX fragment or JSX element)
|
|
77
78
|
const containerPath = jsxElementPath.parentPath;
|
|
78
79
|
const siblings = ensureArray(containerPath.get('children'));
|
|
79
80
|
const index = siblings.findIndex((sibling) => sibling.node === jsxElementPath.node);
|
|
80
81
|
if (index === -1) return false;
|
|
81
82
|
|
|
82
|
-
// Look backward from the current element for a non-empty node.
|
|
83
83
|
for (let index_ = index - 1; index_ >= 0; index_--) {
|
|
84
84
|
const sibling = siblings[index_];
|
|
85
|
-
// Skip over any whitespace (only in JSXText nodes)
|
|
86
85
|
if (sibling.isJSXText() && sibling.node.value.trim() === '') {
|
|
87
86
|
continue;
|
|
88
87
|
}
|
|
89
|
-
// If the sibling is a JSX expression container, check its empty expression's comments.
|
|
90
88
|
if (sibling.isJSXExpressionContainer()) {
|
|
91
89
|
const expression = sibling.get('expression');
|
|
92
90
|
if (expression && expression.node) {
|
|
@@ -95,22 +93,21 @@ export const isIgnoredLine = (path: NodePath<t.JSXOpeningElement>): boolean => {
|
|
|
95
93
|
...(expression.node.trailingComments || []),
|
|
96
94
|
...(expression.node.innerComments || []),
|
|
97
95
|
].map((comment) => comment.value.trim());
|
|
98
|
-
if (comments.some((comment) => comment.includes(
|
|
96
|
+
if (comments.some((comment) => comment.includes(decorator))) {
|
|
99
97
|
return true;
|
|
100
98
|
}
|
|
101
99
|
}
|
|
102
100
|
}
|
|
103
|
-
// Also check if the node itself carries a leadingComments property.
|
|
104
101
|
if (
|
|
105
102
|
sibling.node.leadingComments &&
|
|
106
|
-
sibling.node.leadingComments.some((comment) => comment.value.includes(
|
|
103
|
+
sibling.node.leadingComments.some((comment) => comment.value.includes(decorator))
|
|
107
104
|
) {
|
|
108
105
|
return true;
|
|
109
106
|
}
|
|
110
|
-
break;
|
|
107
|
+
break;
|
|
111
108
|
}
|
|
112
109
|
return false;
|
|
113
|
-
}
|
|
110
|
+
}
|
|
114
111
|
|
|
115
112
|
/**
|
|
116
113
|
* Checks if the path represents a valid JSX component with the specified name.
|
|
@@ -180,8 +177,7 @@ export const isReactNativeImport = (path: NodePath<t.JSXOpeningElement>, expecte
|
|
|
180
177
|
return false;
|
|
181
178
|
};
|
|
182
179
|
|
|
183
|
-
type AncestorClassification = 'safe' | 'text' | 'unknown';
|
|
184
|
-
export type ViewAncestorClassification = AncestorClassification;
|
|
180
|
+
export type AncestorClassification = 'safe' | 'text' | 'unknown';
|
|
185
181
|
type ScopeBinding = NonNullable<ReturnType<NodePath<t.Node>['scope']['getBinding']>>;
|
|
186
182
|
|
|
187
183
|
type AncestorAnalysisContext = {
|
|
@@ -190,11 +186,7 @@ type AncestorAnalysisContext = {
|
|
|
190
186
|
renderExpressionInProgress: WeakSet<t.Node>;
|
|
191
187
|
};
|
|
192
188
|
|
|
193
|
-
export const
|
|
194
|
-
return classifyViewAncestors(path);
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
function classifyViewAncestors(path: NodePath<t.JSXOpeningElement>): AncestorClassification {
|
|
189
|
+
export const getAncestorClassification = (path: NodePath<t.JSXOpeningElement>): AncestorClassification => {
|
|
198
190
|
const context: AncestorAnalysisContext = {
|
|
199
191
|
componentCache: new WeakMap<t.Node, AncestorClassification>(),
|
|
200
192
|
componentInProgress: new WeakSet<t.Node>(),
|
|
@@ -216,7 +208,35 @@ function classifyViewAncestors(path: NodePath<t.JSXOpeningElement>): AncestorCla
|
|
|
216
208
|
}
|
|
217
209
|
|
|
218
210
|
return classification;
|
|
219
|
-
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The ancestor-safety bailout checks shared by the Text and View optimizers. An element nested under a
|
|
215
|
+
* `Text` renders as the inline `NativeVirtualText` host (`RCTVirtualText`) instead of the block
|
|
216
|
+
* `NativeText`/`NativeView` host, so optimizing it would emit the wrong host; an `'unknown'` ancestor
|
|
217
|
+
* chain cannot be proven free of such a `Text`, so it bails too unless the caller opts into the risk.
|
|
218
|
+
*
|
|
219
|
+
* The ancestor walk is lazy (it runs only if these checks are reached) and memoized across the two
|
|
220
|
+
* checks. Each optimizer passes its own resolved `dangerouslyOptimize*WithUnknownAncestors` flag.
|
|
221
|
+
*/
|
|
222
|
+
export const ancestorBailoutChecks = (
|
|
223
|
+
path: NodePath<t.JSXOpeningElement>,
|
|
224
|
+
dangerousOptimizationEnabled: boolean
|
|
225
|
+
): BailoutCheck[] => {
|
|
226
|
+
let classification: AncestorClassification | undefined;
|
|
227
|
+
const classify = () => (classification ??= getAncestorClassification(path));
|
|
228
|
+
|
|
229
|
+
return [
|
|
230
|
+
{
|
|
231
|
+
reason: 'has Text ancestor',
|
|
232
|
+
shouldBail: () => classify() === 'text',
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
reason: 'has unresolved ancestor and dangerous optimization is disabled',
|
|
236
|
+
shouldBail: () => classify() === 'unknown' && !dangerousOptimizationEnabled,
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
};
|
|
220
240
|
|
|
221
241
|
function classifyJSXElementAsAncestor(
|
|
222
242
|
path: NodePath<t.JSXElement>,
|
|
@@ -12,10 +12,12 @@ const ANSI_RESET = '\u001B[0m';
|
|
|
12
12
|
const ANSI_GREEN = '\u001B[32m';
|
|
13
13
|
const ANSI_YELLOW = '\u001B[33m';
|
|
14
14
|
const ANSI_MAGENTA = '\u001B[35m';
|
|
15
|
+
const ANSI_RED = '\u001B[31m';
|
|
15
16
|
|
|
16
17
|
export const noopLogger: PluginLogger = {
|
|
17
18
|
optimized() {},
|
|
18
19
|
skipped() {},
|
|
20
|
+
forced() {},
|
|
19
21
|
warning() {},
|
|
20
22
|
};
|
|
21
23
|
|
|
@@ -30,6 +32,12 @@ export const createLogger = ({ verbose, silent }: { verbose: boolean; silent: bo
|
|
|
30
32
|
if (!verbose) return;
|
|
31
33
|
writeLog('skipped', `Skipped ${payload.component} in ${formatPathLocation(payload.path)} (${payload.reason})`);
|
|
32
34
|
},
|
|
35
|
+
forced(payload) {
|
|
36
|
+
writeLog(
|
|
37
|
+
'forced',
|
|
38
|
+
`Force-optimized ${payload.component} in ${formatPathLocation(payload.path)} (skipped bailout: ${payload.reason})`
|
|
39
|
+
);
|
|
40
|
+
},
|
|
33
41
|
warning(payload) {
|
|
34
42
|
const context = formatWarningContext(payload);
|
|
35
43
|
const message = context.length > 0 ? `${context}: ${payload.message}` : payload.message;
|
|
@@ -52,12 +60,14 @@ function formatWarningContext(payload: WarningLogPayload): string {
|
|
|
52
60
|
return location;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
|
-
|
|
63
|
+
type LogLevel = 'optimized' | 'skipped' | 'forced' | 'warning';
|
|
64
|
+
|
|
65
|
+
function writeLog(level: LogLevel, message: string): void {
|
|
56
66
|
const levelTag = formatLevel(level);
|
|
57
67
|
console.log(`${LOG_PREFIX} ${levelTag} ${message}`);
|
|
58
68
|
}
|
|
59
69
|
|
|
60
|
-
function formatLevel(level:
|
|
70
|
+
function formatLevel(level: LogLevel): string {
|
|
61
71
|
if (level === 'optimized') {
|
|
62
72
|
return colorize('[optimized]', ANSI_GREEN);
|
|
63
73
|
}
|
|
@@ -66,6 +76,10 @@ function formatLevel(level: 'optimized' | 'skipped' | 'warning'): string {
|
|
|
66
76
|
return colorize('[skipped]', ANSI_YELLOW);
|
|
67
77
|
}
|
|
68
78
|
|
|
79
|
+
if (level === 'forced') {
|
|
80
|
+
return colorize('[forced]', ANSI_RED);
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
return colorize('[warning]', ANSI_MAGENTA);
|
|
70
84
|
}
|
|
71
85
|
|
package/src/runtime/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TextProps, TextStyle, StyleSheet } from 'react-native';
|
|
1
|
+
import { TextProps, TextStyle, StyleSheet, Platform } from 'react-native';
|
|
2
2
|
import { GenericStyleProp } from './types';
|
|
3
3
|
import { userSelectToSelectableMap, verticalAlignToTextAlignVerticalMap } from './utils/constants';
|
|
4
4
|
|
|
@@ -49,14 +49,28 @@ export function processTextStyle(style: GenericStyleProp<TextStyle>): Partial<Te
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
52
|
+
* The default value `Text` resolves for `accessible` when the prop is omitted: `true` on iOS (text is
|
|
53
|
+
* an accessibility element unless opted out), `false` on Android, and `undefined` elsewhere.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* Runtime fallback for the common optimized `<Text>` path (no accessibility props) when the target
|
|
57
|
+
* platform is unknown at build time. When it is known (Metro reports it on the Babel caller), the
|
|
58
|
+
* plugin inlines the literal instead and this is not emitted. Evaluated per render — like `Text`'s own
|
|
59
|
+
* `Platform.select` — rather than hoisted to a constant, so it always reflects the current platform.
|
|
60
|
+
*/
|
|
61
|
+
export const getDefaultTextAccessible = (): boolean | undefined => Platform.select({ ios: true, android: false });
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalizes accessibility and ARIA props for runtime native components, mirroring the reconciliation
|
|
65
|
+
* `Text` performs before handing off to its native host.
|
|
53
66
|
*
|
|
54
67
|
* @param props - Accessibility and ARIA props.
|
|
55
68
|
* @returns Props with normalized accessibility fields.
|
|
56
69
|
* @remarks
|
|
57
70
|
* - Merges `aria-label` with `accessibilityLabel`
|
|
58
71
|
* - Merges ARIA state fields into `accessibilityState`
|
|
59
|
-
* -
|
|
72
|
+
* - Reconciles `disabled` with `accessibilityState.disabled` (the explicit `disabled` prop wins)
|
|
73
|
+
* - Resolves the platform-specific `accessible` default (see {@link getDefaultTextAccessible})
|
|
60
74
|
*/
|
|
61
75
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
76
|
export function processAccessibilityProps(props: Record<string, any>): Record<string, any> {
|
|
@@ -70,6 +84,7 @@ export function processAccessibilityProps(props: Record<string, any>): Record<st
|
|
|
70
84
|
['aria-expanded']: ariaExpanded,
|
|
71
85
|
['aria-selected']: ariaSelected,
|
|
72
86
|
accessible,
|
|
87
|
+
disabled,
|
|
73
88
|
...restProperties
|
|
74
89
|
} = props;
|
|
75
90
|
|
|
@@ -97,14 +112,33 @@ export function processAccessibilityProps(props: Record<string, any>): Record<st
|
|
|
97
112
|
};
|
|
98
113
|
}
|
|
99
114
|
|
|
100
|
-
//
|
|
101
|
-
|
|
115
|
+
// Reconcile `disabled` with `accessibilityState.disabled`. When the two are out of sync (and not
|
|
116
|
+
// both falsy) the explicit `disabled` prop wins and is mirrored back into the state object, so the
|
|
117
|
+
// native host receives a consistent value on both fields.
|
|
118
|
+
const stateDisabled = normalizedState?.disabled;
|
|
119
|
+
const normalizedDisabled = disabled ?? stateDisabled;
|
|
120
|
+
if (
|
|
121
|
+
normalizedDisabled !== stateDisabled &&
|
|
122
|
+
((normalizedDisabled != null && normalizedDisabled !== false) || (stateDisabled != null && stateDisabled !== false))
|
|
123
|
+
) {
|
|
124
|
+
normalizedState = { ...normalizedState, disabled: normalizedDisabled };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Resolve `accessible` exactly as `Text` does: opt-out on iOS, off by default on Android. The
|
|
128
|
+
// Android pressable case (`onPress`/`onLongPress`) never applies — press handlers bail out of
|
|
129
|
+
// optimization — so an omitted prop falls back to the platform default.
|
|
130
|
+
const normalizedAccessible = Platform.select({
|
|
131
|
+
ios: accessible !== false,
|
|
132
|
+
android: accessible ?? false,
|
|
133
|
+
default: accessible,
|
|
134
|
+
});
|
|
102
135
|
|
|
103
136
|
return {
|
|
104
137
|
...restProperties,
|
|
105
138
|
accessibilityLabel: normalizedLabel,
|
|
106
139
|
accessibilityState: normalizedState,
|
|
107
140
|
accessible: normalizedAccessible,
|
|
141
|
+
disabled: normalizedDisabled,
|
|
108
142
|
};
|
|
109
143
|
}
|
|
110
144
|
|
package/src/runtime/index.web.ts
CHANGED
|
@@ -5,6 +5,11 @@ import { GenericStyleProp } from './types';
|
|
|
5
5
|
|
|
6
6
|
export const processTextStyle = (style: GenericStyleProp<TextStyle>) => ({ style }) as Partial<TextProps>;
|
|
7
7
|
|
|
8
|
+
// On Web there is no platform-specific `accessible` default to apply; react-native-web's `Text`
|
|
9
|
+
// derives accessibility from the rendered DOM. Returning `undefined` makes the injected
|
|
10
|
+
// `accessible={getDefaultTextAccessible()}` a no-op.
|
|
11
|
+
export const getDefaultTextAccessible = (): boolean | undefined => undefined;
|
|
12
|
+
|
|
8
13
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
14
|
export function processAccessibilityProps(props: Record<string, any>): Record<string, any> {
|
|
10
15
|
return props;
|