Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,13 @@ export const flattenStyle = (style: unknown): unknown => {
* before the clean matters: a trailing `{ key: undefined }` array entry must apply as a last-wins
* override (then get dropped here) rather than being dropped inside its own element first.
*/
export const normalize = (props: Record<string, unknown>) =>
JSON.parse(JSON.stringify('style' in props ? { ...props, style: flattenStyle(props.style) } : props));
export const normalize = (props: Record<string, unknown>) => {
const normalized = JSON.parse(
JSON.stringify('style' in props ? { ...props, style: flattenStyle(props.style) } : props)
);

// benign: native flattening treats a null style like an absent style.
if (normalized.style === null) delete normalized.style;

return normalized;
};
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const TEXT_CASES = [
'<Text aria-hidden={false}>hello</Text>',
'<Text aria-hidden accessibilityElementsHidden={false}>hello</Text>',
'<Text style={{ color: "red" }}>hello</Text>', // styled, no a11y: `accessible` default must survive the build-time style
'<Text style={null} adjustsFontSizeToFit={false}>hello</Text>',
'<Text style={{ color: "red" }} accessibilityLabel="x">hello</Text>',
// `selectionColor` runs through `processColor` (a non-identity mock packs "red" → an int), so both
// sides must emit the packed value — proving Boost calls processColor, not a raw forward.
Expand All @@ -46,6 +47,7 @@ const TEXT_CASES = [
'<Text style={{ fontWeight: 700 }}>hello</Text>', // numeric fontWeight → string
'<Text style={{ verticalAlign: "middle" }}>hello</Text>', // verticalAlign → textAlignVertical
'<Text style={{ userSelect: "none", color: "red" }}>hello</Text>', // userSelect → selectable
'<Text style={{ userSelect: "xyz", fontWeight: 400 }} selectable={true}>hello</Text>',
'<Text style={[{ color: "red" }, { fontSize: 16 }]}>hello</Text>', // array merged (last wins)
// `id` → `nativeID` build-time rename; `id` wins over an explicit `nativeID`.
'<Text id="x">hello</Text>',
Expand Down Expand Up @@ -115,6 +117,28 @@ describe('differential parity', () => {
expect(normalize(boost.props)).toEqual(normalize(wrapper.props));
});

it('Text: mixed dynamic style preserves userSelect flatten order', async () => {
const jsx = '<Text style={[dynamicStyle, { userSelect: "text" }]} selectable={true}>hello</Text>';
const preamble = 'const dynamicStyle = { userSelect: "none" };';
const boost = await captureBoost(os, jsx, preamble);
expect(boost.optimized).toBe(true);
if (!boost.optimized) throw new Error('expected Text mixed style case to optimize');
const wrapper = await captureWrapper(os, jsx, preamble);
expect(boost.which).toEqual(wrapper.which);
expect(normalize(boost.props)).toEqual(normalize(wrapper.props));
});

it('Text: invalid userSelect clobbers selectable with shadowed undefined', async () => {
const jsx = '<Text style={{ userSelect: "xyz" }} selectable={true}>hello</Text>';
const preamble = 'const undefined = true;';
const boost = await captureBoost(os, jsx, preamble);
expect(boost.optimized).toBe(true);
if (!boost.optimized) throw new Error('expected Text shadowed undefined case to optimize');
const wrapper = await captureWrapper(os, jsx, preamble);
expect(boost.which).toEqual(wrapper.which);
expect(normalize(boost.props)).toEqual(normalize(wrapper.props));
});

it.each(VIEW_CASES)('View: %s', async (jsx) => {
const boost = await captureBoost(os, jsx);
expect(boost.optimized).toBe(!BAILED_VIEW_CASES.has(jsx));
Expand Down
10 changes: 7 additions & 3 deletions packages/react-native-boost/src/plugin/optimizers/text/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,12 +330,16 @@ function processProps(
// `passStyleByIdentity` (Unistyles routing) skips all style transforms — the original `style`
// attribute is left untouched in the collected attributes below so it reaches the native host intact.
if (styleExpr && !passStyleByIdentity) {
const selectableValue = extractSelectableAndUpdateStyle(styleExpr);
const selectable = extractSelectableAndUpdateStyle(styleExpr);

if (selectableValue != null) {
if (selectable) {
selectableAttribute = t.jsxAttribute(
t.jsxIdentifier('selectable'),
t.jsxExpressionContainer(t.booleanLiteral(selectableValue))
t.jsxExpressionContainer(
selectable.value === undefined
? t.unaryExpression('void', t.numericLiteral(0))
: t.booleanLiteral(selectable.value)
)
);
}

Expand Down
98 changes: 55 additions & 43 deletions packages/react-native-boost/src/plugin/utils/common/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,59 +401,71 @@ export function extractSelectionColor(attributes: Array<t.JSXAttribute | t.JSXSp
return {};
}

type SelectableExtraction = { value: boolean | undefined };

const isUserSelectProperty = (property: t.ObjectProperty): boolean =>
t.isIdentifier(property.key, { name: 'userSelect' }) ||
(t.isStringLiteral(property.key) && property.key.value === 'userSelect');

const isNullishExpression = (expression: t.Expression): boolean =>
t.isNullLiteral(expression) || t.isIdentifier(expression, { name: 'undefined' });

const canResolveUserSelect = (expression: t.Expression): boolean =>
t.isStringLiteral(expression) || t.isNumericLiteral(expression) || t.isBooleanLiteral(expression);

const removeUserSelectProperties = (objectExpr: t.ObjectExpression) => {
objectExpr.properties = objectExpr.properties.filter(
(property) => !t.isObjectProperty(property) || !isUserSelectProperty(property)
);
};

/**
* Attempts to statically extract the `userSelect` style property from a style expression.
* Attempts to statically extract the final flattened `userSelect` style value from a style expression.
*
* If the `userSelect` value can be resolved at compile-time, the property is removed from the
* object literal (or array element) and its mapped boolean value for the native `selectable`
* prop is returned. When the value is unknown or the expression is not statically analysable,
* `undefined` is returned and no modification is made.
* A non-null `userSelect` always overrides the direct `selectable` prop in RN, even when the value is
* unknown to RN's map and therefore resolves to `undefined`. Dynamic/nullish values are left for the
* runtime helper so it can preserve RN's `processedStyle.userSelect != null` check.
*/
function extractSelectableFromObjectExpression(objectExpr: t.ObjectExpression): boolean | undefined {
let selectableValue: boolean | undefined;

objectExpr.properties = objectExpr.properties.filter((property) => {
if (
!t.isObjectProperty(property) ||
(!t.isIdentifier(property.key, { name: 'userSelect' }) &&
!(t.isStringLiteral(property.key) && property.key.value === 'userSelect'))
) {
return true; // keep property
}

if (t.isStringLiteral(property.value)) {
const mapped = USER_SELECT_STYLE_TO_SELECTABLE_PROP[property.value.value];
if (mapped !== undefined) {
selectableValue = mapped;
export function extractSelectableAndUpdateStyle(styleExpr: t.Expression): SelectableExtraction | undefined {
const candidates: Array<{ object: t.ObjectExpression; value: t.Expression }> = [];
let hasUnresolvedStylePart = false;

const collect = (objectExpr: t.ObjectExpression) => {
for (const property of objectExpr.properties) {
if (!t.isObjectProperty(property) || property.computed) {
hasUnresolvedStylePart = true;
continue;
}
if (isUserSelectProperty(property) && t.isExpression(property.value)) {
candidates.push({ object: objectExpr, value: property.value });
}
}
};

// Remove the `userSelect` property
return false;
});
const visitStyle = (expr: t.Expression | t.SpreadElement | null) => {
if (expr == null) return;
if (t.isObjectExpression(expr)) {
collect(expr);
return;
}
if (t.isArrayExpression(expr)) {
for (const element of expr.elements) visitStyle(element);
return;
}
if (!isSkippableFalsyElement(expr)) hasUnresolvedStylePart = true;
};

return selectableValue;
}
visitStyle(styleExpr);
if (hasUnresolvedStylePart) return undefined;

export function extractSelectableAndUpdateStyle(styleExpr: t.Expression): boolean | undefined {
if (t.isObjectExpression(styleExpr)) {
return extractSelectableFromObjectExpression(styleExpr);
}
const last = candidates.at(-1);
if (!last || isNullishExpression(last.value) || !canResolveUserSelect(last.value)) return undefined;

if (t.isArrayExpression(styleExpr)) {
let selectableValue: boolean | undefined;
for (const element of styleExpr.elements) {
if (element && t.isObjectExpression(element)) {
const value = extractSelectableFromObjectExpression(element);
if (value !== undefined) {
selectableValue = value; // prefer last defined value
}
}
}
return selectableValue;
}
for (const { object } of candidates) removeUserSelectProperties(object);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return undefined; // not statically analysable
return {
value: t.isStringLiteral(last.value) ? USER_SELECT_STYLE_TO_SELECTABLE_PROP[last.value.value] : undefined,
};
}

/**
Expand Down