Skip to content

fix(react-native): stop false-positives on Expo Universal UI (@expo/ui)#645

Merged
aidenybai merged 5 commits into
mainfrom
cursor/expo-ui-listitem-raw-text-442f
Jun 2, 2026
Merged

fix(react-native): stop false-positives on Expo Universal UI (@expo/ui)#645
aidenybai merged 5 commits into
mainfrom
cursor/expo-ui-listitem-raw-text-442f

Conversation

@aidenybai
Copy link
Copy Markdown
Member

@aidenybai aidenybai commented Jun 2, 2026

Problem

On Expo SDK 56 with Universal UI (@expo/ui), rn-no-raw-text reports a wall of false positives — e.g. "Raw text outside a Text component" ×69 — for valid code:

import { Host, List, ListItem } from "@expo/ui";

<List>
  <ListItem onPress={() => {}}>Settings</ListItem>
</List>

@expo/ui is a native UI layer (it delegates to SwiftUI / Jetpack Compose), not React Native's core primitives, so several RN-core assumptions don't hold for its components.

Investigation — which @expo/ui components / rules are actually affected

I audited the full Universal UI component set against the React Native rules:

@expo/ui component Renders raw text? Notes
ListItem (+ Leading/Supporting/Trailing slots) Yes Headline auto-wraps strings in native text → rn-no-raw-text false positive
Button No Uses a label prop; children doc says "Only nested elements are supported, not plain strings"
Collapsible No Header text is the label prop; content is always <Text>-wrapped
Text n/a Already covered by the name heuristic
Column / Row / Spacer No Layout primitives — Expo's own docs wrap their text in <Text>
ScrollView n/a Native scroll container — collides with rn-no-scrollview-mapped-list

Rule audit: most RN rules are already safe because they're import-gated to react-native (rn-no-image-children, rn-prefer-expo-image, rn-no-deprecated-modules), package-gated (rn-bottom-sheet-prefer-native), or prop-shape/behavior-gated (rn-scrollview-flex-in-content-container, rn-scrollview-dynamic-padding, rn-no-scroll-state). The two genuinely affected rules are name-only gated.

Fixes

Both fixes share a new reusable helper, isExpoUiComponentElement, that resolves whether a JSX element is a given @expo/ui export — covering named, renamed, slot-member (<ListItem.Supporting>), and namespace (<ExpoUI.ListItem>) imports across the @expo/ui root and the @expo/ui/swift-ui / @expo/ui/jetpack-compose subpaths. Gating on the import (not the name) means same-named components from other libraries — or with no import — still report. The helper takes the target componentName so rn-no-raw-text suppresses only ListItem (raw text in Column/Row still fires).

  1. rn-no-raw-text now treats <ListItem> and its compound slot markers as text-handling.
  2. rn-no-scrollview-mapped-list no longer flags mapped children inside an @expo/ui <ScrollView> — RN's virtualized lists can't compose inside the native <Host> tree, and @expo/ui ships its own virtualized <List>, so the FlashList/FlatList advice was actively wrong there.

The ESLint mirror (eslint-plugin-react-doctor) re-exports the oxlint rules directly, so both fixes propagate there automatically.

Changes

  • constants/react-native.ts: add EXPO_UI_MODULE_SOURCES (the root + two platform subpaths).
  • rules/react-native/utils/is-expo-ui-component-element.ts: new reusable helper (built on existing flattenJsxName + import-source utilities).
  • rules/react-native/rn-no-raw-text.ts & rn-no-scrollview-mapped-list.ts: one early-return each via the helper, with the target component name passed inline (matching rn-no-image-children's inline "react-native"/"Image" pattern).
  • Tests: 9 new rn-no-raw-text cases + a new rn-no-scrollview-mapped-list test file (the rule previously had none).
  • Changeset (react-doctor: patch).

Verification

  • rn-no-raw-text (18/18) and rn-no-scrollview-mapped-list (6/6) pass.
  • typecheck, lint, format clean on changed files.
  • The 2 failing tests in the broader react-native suite (rn-bottom-sheet-prefer-native, rn-prefer-pressable-over-gesture-detector) are pre-existing and unrelated — confirmed failing on the base branch with my changes stashed.

Refs: https://docs.expo.dev/versions/v56.0.0/sdk/ui/universal/list/ · https://docs.expo.dev/versions/v56.0.0/sdk/ui/universal/

Open in Web Open in Cursor 

Universal UI (@expo/ui) ListItem renders raw string children in the native
headline text area, and its compound slot markers (Leading/Supporting/Trailing)
forward strings into native text — so raw text is safe, unlike RN's core View.
Recognize them as text-handling, gated on the @expo/ui import (root and the
swift-ui/jetpack-compose subpaths, plus renamed and namespace imports) so a
same-named custom ListItem in a plain RN app still reports.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 2, 2026

Open in StackBlitz

npm i https://pkg.pr.new/eslint-plugin-react-doctor@645
npm i https://pkg.pr.new/oxlint-plugin-react-doctor@645
npm i https://pkg.pr.new/react-doctor@645

commit: dcf0d96

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

React Doctor

React Doctor found 10 files changed in this pull request, but none matched the files covered by its enabled checks.

Scope: 10 files changed on cursor/expo-ui-listitem-raw-text-442f vs. main.

View workflow run

Generated by React Doctor. Questions? Contact founders@million.dev.

Generalize the @expo/ui element detection into a reusable
isExpoUiComponentElement helper (covers named, renamed, slot-member, and
namespace imports across the @expo/ui root and swift-ui/jetpack-compose
subpaths) and reuse it from both rn-no-raw-text (ListItem) and
rn-no-scrollview-mapped-list (ScrollView).

@expo/ui's ScrollView is a native scroll container — RN's virtualized lists
can't compose inside its Host tree and @expo/ui ships its own List — so the
FlashList/FlatList advice was a wrong-advice false positive there. Add the
rule's first unit tests covering the exclusion and the still-fires cases.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@cursor cursor Bot changed the title fix(rn-no-raw-text): stop false-positives on Expo Universal UI ListItem fix(react-native): stop false-positives on Expo Universal UI (@expo/ui) Jun 2, 2026
@aidenybai aidenybai marked this pull request as ready for review June 2, 2026 05:48
cursoragent and others added 2 commits June 2, 2026 05:51
Drop the two single-use EXPO_UI_LIST_ITEM_COMPONENT / EXPO_UI_SCROLL_VIEW_COMPONENT
constants and pass the names inline, matching the canonical pattern in this rule
family (rn-no-image-children inlines "react-native"/"Image"). Removes a layer of
indirection and the triplicated rationale comment; the per-rule rationale now
lives only at each call site. EXPO_UI_MODULE_SOURCES (a reused multi-value set)
stays.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
rn-bottom-sheet-prefer-native and rn-prefer-pressable-over-gesture-detector
moved their actionable fix text into the `recommendation` field and reworded
`message` to the user-impact style, but two test assertions still checked the
old fix substrings ("prefer <Modal", "Pressable") against `message`. Assert
substrings that are actually present in the current messages, matching the
message-substring convention used across the sibling rule tests.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4f054b2. Configure here.

Bugbot: isExpoUiNamespaceImport used isImportedFromModule, which matches any
import type (named/default/namespace) by source — so a named @expo/ui import
reused via member access (<Row.ListItem>) was wrongly treated as the namespace
form and suppressed. Add a namespace-specific isNamespaceImportFromModule
predicate to the canonical import-source util (checks ImportInfo.isNamespace)
and use it. Add a regression test locking the named-vs-namespace boundary.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@aidenybai aidenybai merged commit 4aadaab into main Jun 2, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants