refactor: replace React.Children usage to more composition-friendly alternatives (#4989)#5018
refactor: replace React.Children usage to more composition-friendly alternatives (#4989)#5018matkoson wants to merge 11 commits into
Conversation
…th context List.Accordion used React.Children.map + cloneElement to inject paddingLeft and theme into expanded children, which makes composition harder (children had to be direct, prop-mergeable elements). Replace it with ListAccordionContext: the accordion exposes whether descendants should indent (leftIndent), and List.Item consumes it, applying the indent to its own container so the ripple/background stays full-width. Behavior preserved for List.Item children; theme now flows via context. Adds characterization tests. Refs callstack#4989.
Dialog cloned its children with React.Children + cloneElement to inject the theme, and DialogActions inspected the child count/index to space the first and last action. Both couple the components to the exact shape of their children. Remove the cloning: Dialog.Title/Content/Actions/Icon resolve the theme themselves via useInternalTheme, and DialogActions spaces its actions with a container `gap` instead of per-child injection. BREAKING CHANGE: Dialog no longer clones its children to forward an injected theme. The subcomponents read the theme from context (PaperProvider) or an explicit `theme` prop, so pass `theme` directly to a Dialog subcomponent if you previously relied on Dialog forwarding it. Wrapping or conditionally rendering Dialog.Actions children no longer affects their spacing. Refs callstack#4989
Card used React.Children.count/map + cloneElement to inspect sibling position and inject props (the theme, and top padding for the first child) into Card.Content/Cover/Title/Actions. That makes composition fragile: children had to be direct, recognised elements. Render the children directly instead and let each subcomponent own its own theme resolution and padding. BREAKING CHANGE: Card no longer clones its children to inject a theme or sibling-derived padding. Card.Content/Cover/Title/Actions read the theme from context or an explicit `theme` prop and apply their own spacing, so wrapping or reordering them keeps working; pass `theme` directly if you previously relied on Card forwarding it. Refs callstack#4989
ToggleButton.Row used React.Children.count/map + cloneElement to inject a first/middle/last border radius into each button by position, which only works when the buttons are direct children. Replace it with ToggleButtonRowContext: the row flags its descendants as segmented, each ToggleButton reads the flag and renders the flat segment shape itself, and the row clips a single rounded container with hairline dividers between segments. BREAKING CHANGE: ToggleButton.Row no longer clones its children to inject per-segment border radii. Segment styling now comes from ToggleButtonRowContext, so the buttons may be wrapped or conditionally rendered and still pick up the segmented appearance. Refs callstack#4989
Use the MD3 segmented-button shape token (corner.largeIncreased) for the row container and secondaryContainer for the selected segment, matching the existing SegmentedButtons component. Full per-segment alignment (first/middle/last radius as in SegmentedButtons) is intentionally NOT done: it would require positional child inspection, the React.Children anti-pattern this refactor removes. The container-clip + divider approach keeps the segmented look composition-friendly. Refs callstack#4989
Replace the `cloneElement` prop injection and `displayName`-based filtering in Appbar with an `AppbarContext` that exposes the shared `isDark` and `mode` values. `Appbar.Action`, `Appbar.BackAction` and `Appbar.Content` now read those values from context and derive their own foreground color, instead of having `color`/`mode`/`theme` spliced into them. - `small` and `center-aligned` render their children directly in author order; the title's `flex: 1` keeps trailing actions right-aligned and the center-aligned title centers itself. - `medium` and `large` keep the two-row layout (controls row above a full-width title) by partitioning children for placement only — no props are injected. - Drops the `React.Children.forEach` count heuristic that conditionally centered the title in `center-aligned`. BREAKING CHANGE: Appbar children are now rendered in the order they are written rather than being reordered to put the back action first. Place `Appbar.BackAction` before `Appbar.Content` and trailing `Appbar.Action`s after it, as shown in the docs. Children no longer receive injected `color`/`mode`/`theme` props; wrapping `Appbar.Content`/`Appbar.Action` in another element keeps working because the values come from context.
The composition refactor's job is to remove React.Children/cloneElement
without changing what users see. A separate commit on this branch
("align segmented row to MD3 spec") went further: it changed
ToggleButton.Row's corner radius (extraSmall -> largeIncreased) and the
selected segment's fill (own color -> secondaryContainer), converging it
toward the MD3 SegmentedButtons component.
That redesign was scope creep. ToggleButton.Row was never styled to the
MD3 SegmentedButtons spec upstream (it used surfaceContainerHighest /
extraSmall corners, distinct from SegmentedButtons' secondaryContainer /
largeIncreased) -- it's a separate, legacy component identity, not an
under-spec implementation of SegmentedButtons. The acceptance criterion
that triggered the MD3 pass ("double-check any affected component still
follows the specs") is a non-regression check, not a redesign mandate.
Revert ToggleButton.Row's corner back to theme.shapes.corner.extraSmall
and ToggleButton's selected fill back to the unconditional
getToggleButtonColor(...) (no row-specific override), matching the
original upstream component exactly. The composition-safe technique from
the prior commit (ToggleButtonRowContext, container-clip + gap divider)
is untouched -- this only reverts the two MD3-redesign value changes on
top of it, restoring a visually transparent composition refactor.
Verified: tsc clean, eslint clean, ToggleButton suite 8/9 (the 1 failure
is the pre-existing baseline animation test, unchanged). Pixel diff
against the prior B+ screenshot confirms the change is real and scoped
to exactly the Row control (4156px, corner + selected-fill only) with
zero diff elsewhere on screen.
9a9da84 to
b3d0b92
Compare
There was a problem hiding this comment.
Pull request overview
Refactors several UI components to avoid React.Children inspection and cloneElement, improving composition (wrapped children, custom components) by moving styling/spacing responsibility into contexts and container-owned layout.
Changes:
- Reworks
ToggleButton.Rowto use context-driven segmented styling (no positional cloning) and adds optional theming. - Updates
List.Accordionindentation behavior via context so wrappedList.Itemchildren indent correctly without prop injection. - Refactors
Dialog,Dialog.Actions, andCardsections to remove child cloning, shifting spacing/shape behavior to containers and updating tests/examples accordingly.
Reviewed changes
Copilot reviewed 30 out of 30 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/ToggleButton/ToggleButtonRowContext.tsx | Adds context to signal segmented-row rendering to descendant toggle buttons. |
| src/components/ToggleButton/ToggleButtonRow.tsx | Replaces child cloning with container-owned segmented styling + context provider. |
| src/components/ToggleButton/ToggleButton.tsx | Consumes row context to adjust styling for segmented rows. |
| src/components/List/ListItem.tsx | Applies indentation based on accordion context when no left/right slots are provided. |
| src/components/List/ListAccordionContext.tsx | Adds context used to coordinate indentation behavior for accordion descendants. |
| src/components/List/ListAccordion.tsx | Removes child cloning and provides indentation behavior via context. |
| src/components/Dialog/DialogTitle.tsx | Removes title’s own top offset to defer inset ownership to Dialog container. |
| src/components/Dialog/DialogIcon.tsx | Removes icon top padding, preserves icon-to-title spacing, adds testID. |
| src/components/Dialog/DialogActions.tsx | Stops injecting props/styles into action buttons; uses container spacing. |
| src/components/Dialog/Dialog.tsx | Removes first-child margin injection and moves top inset to dialog surface padding. |
| src/components/Card/utils.tsx | Simplifies cover styling helper signature after removing index/total injection. |
| src/components/Card/CardTitle.tsx | Removes internal positional props previously injected by Card. |
| src/components/Card/CardCover.tsx | Removes internal positional props and updates cover style calculation callsite. |
| src/components/Card/CardContent.tsx | Makes vertical padding uniform (no neighbor/position-based heuristics). |
| src/components/Card/CardActions.tsx | Stops injecting props into buttons and switches to container spacing. |
| src/components/Card/Card.tsx | Removes child cloning/indexing, clips content to card shape via overflow + radius. |
| src/components/tests/ToggleButton.test.tsx | Adds characterization test for segmented-row context styling. |
| src/components/tests/ListAccordion.test.tsx | Adds tests covering accordion child indentation via context. |
| src/components/tests/Dialog.test.tsx | Updates tests for surface-owned top inset + no action prop injection. |
| src/components/tests/Card/Card.test.tsx | Adds tests for clipping, uniform content padding, and no prop injection. |
| src/components/tests/Card/snapshots/Card.test.tsx.snap | Updates snapshot for inner-container clipping/radius styling. |
| example/src/Examples/TeamDetails.tsx | Updates examples for Card.Actions breaking change (explicit button mode). |
| example/src/Examples/CardExample.tsx | Updates examples for Card.Actions breaking change (explicit button mode). |
| const DialogActions = (props: Props) => { | ||
| useInternalTheme(props.theme); | ||
| const actionsLength = React.Children.toArray(props.children).length; | ||
|
|
||
| return ( | ||
| <View {...props} style={[styles.v3Container, props.style]}> | ||
| {React.Children.map(props.children, (child, i) => | ||
| React.isValidElement<DialogActionChildProps>(child) | ||
| ? React.cloneElement(child, { | ||
| compact: true, | ||
| uppercase: false, | ||
| style: [ | ||
| { | ||
| marginRight: i + 1 === actionsLength ? 0 : 8, | ||
| }, | ||
| child.props.style, | ||
| ], | ||
| }) | ||
| : child | ||
| )} | ||
| {props.children} | ||
| </View> |
There was a problem hiding this comment.
Addressed in 0d08f46 — DialogActions now destructures theme, style and children and spreads only the remaining ViewProps onto the View, so the custom theme prop is no longer forwarded to the native element.
| <ToggleButtonRowContext.Provider value={SEGMENTED_ROW_CONTEXT}> | ||
| <View style={style}> | ||
| <View | ||
| style={[ | ||
| styles.row, |
There was a problem hiding this comment.
Fixed in 0d08f46 — removed the extra wrapper View and applied style directly to the segmented row container, so callers can override the row's background / border-radius / padding again.
| row: { | ||
| flexDirection: 'row', | ||
| }, | ||
| button: { | ||
| borderWidth: StyleSheet.hairlineWidth, | ||
| }, | ||
|
|
||
| first: { | ||
| borderTopRightRadius: 0, | ||
| borderBottomRightRadius: 0, | ||
| }, | ||
|
|
||
| middle: { | ||
| borderRadius: 0, | ||
| borderLeftWidth: 0, | ||
| }, | ||
|
|
||
| last: { | ||
| borderLeftWidth: 0, | ||
| borderTopLeftRadius: 0, | ||
| borderBottomLeftRadius: 0, | ||
| alignSelf: 'flex-start', | ||
| gap: StyleSheet.hairlineWidth, | ||
| overflow: 'hidden', | ||
| padding: StyleSheet.hairlineWidth, | ||
| }, |
There was a problem hiding this comment.
Fixed in 0d08f46 — replaced the row gap with a per-segment hairline marginLeft (and dropped the row's left padding). Dividers now render without depending on flexbox gap, so older React Native versions (peer react-native: "*") keep the segmented separators.
| <View style={style}> | ||
| <View | ||
| style={[ | ||
| styles.row, | ||
| { | ||
| backgroundColor: outlineColor, | ||
| borderRadius, | ||
| }, | ||
| ]} | ||
| > | ||
| {children} | ||
| </View> | ||
| </View> |
| flexDirection: 'row', | ||
| }, | ||
| button: { | ||
| borderWidth: StyleSheet.hairlineWidth, | ||
| }, | ||
|
|
||
| first: { | ||
| borderTopRightRadius: 0, | ||
| borderBottomRightRadius: 0, | ||
| }, | ||
|
|
||
| middle: { | ||
| borderRadius: 0, | ||
| borderLeftWidth: 0, | ||
| }, | ||
|
|
||
| last: { | ||
| borderLeftWidth: 0, | ||
| borderTopLeftRadius: 0, | ||
| borderBottomLeftRadius: 0, | ||
| alignSelf: 'flex-start', | ||
| gap: StyleSheet.hairlineWidth, | ||
| overflow: 'hidden', | ||
| padding: StyleSheet.hairlineWidth, |
| container: { | ||
| flexDirection: 'row', | ||
| alignItems: 'center', | ||
| gap: 8, | ||
| padding: 8, | ||
| }, |
| v3Container: { | ||
| flexDirection: 'row', | ||
| flexGrow: 1, | ||
| alignItems: 'center', | ||
| justifyContent: 'flex-end', | ||
| gap: 8, | ||
| paddingBottom: 24, | ||
| paddingHorizontal: 24, | ||
| }, |
…factor - DialogActions: destructure `theme`/`style`/`children` and forward only the remaining ViewProps to the native View, so the custom `theme` prop is not spread onto it (avoids react-native-web warnings and prop leakage). - ToggleButton.Row: apply the `style` prop to the segmented row container instead of an extra wrapper View, so callers can override the row's background, border radius and padding again. - ToggleButton.Row: replace the flexbox `gap` divider with a per-segment hairline `marginLeft` (dropping the row's left padding), so segment dividers render on React Native versions without `gap` support; the peer dependency is `react-native: "*"`. Refs callstack#4989
| container: { | ||
| flexDirection: 'row', | ||
| alignItems: 'center', | ||
| gap: 8, | ||
| padding: 8, | ||
| }, |
| const items = React.Children.toArray(children).filter( | ||
| React.isValidElement | ||
| ) as React.ReactElement<AppbarChildProps>[]; | ||
| const titleItems = items.filter((child) => child.type === AppbarContent); | ||
| const actionItems = items.filter((child) => child.type !== AppbarContent); | ||
| const leadingActions = actionItems.filter((child) => child.props.isLeading); | ||
| const trailingActions = actionItems.filter( | ||
| (child) => !child.props.isLeading | ||
| ); |
| justifyContent: 'flex-end', | ||
| gap: 8, | ||
| paddingBottom: 24, |
Motivation
Several components inspect, classify, or mutate their children with
React.Children+cloneElement. This makes composition hard: children must be direct, prop-mergeable elements of an expected type, so wrapping them (in a memo, a custom component, a helper) silently breaks the injected styling/spacing/behavior, and the injected props are not type-safe. The FAB Menu rewrite (#4963) addressed the same class of problem; this PR migrates the remaining offenders to typed props, context, and container-owned layout.Approach
No child introspection. Each section component owns its own spacing/styling, cross-cutting state flows through context, and parents own layout (gap, padding, clipping) instead of cloning children. v6 alpha allows breaking changes without deprecation aliases, but user-visible output is preserved for documented usage; where a default that required child introspection is dropped, it is called out as a breaking change.
Changes
List.Accordion
React.Children.map+cloneElementthat injectedpaddingLeftandthemeinto expanded children.ListAccordionContext({ leftIndent }).List.Itemconsumes it and indents its own container when it renders noleft/right. Ripple/background stay full-width.List.Itemchildren (zero snapshot churn).Dialog
cloneElementthat injectedmarginTop: 24.paddingTop: 24);Dialog.TitleandDialog.Iconno longer add their own top offset.Dialog.Iconkeeps a 16px bottom gap so icon-to-title spacing is preserved.Dialog.Actions
compactanduppercaseinto action buttons. Set them explicitly on each button. Inter-action spacing is now containergap.Card
displayNamescanning, and the per-childcloneElementinjection ofindex/total/siblings/borderRadiusStyles.overflow: hidden+ combined border radius) instead of injecting corner styles intoCard.Cover/Card.Title.Card.Contentnow uses uniform vertical padding regardless of neighboring sections.Card.Actions
mode(previouslyoutlinedfor the first action,containedfor the rest) orcompact. Set button props explicitly. Spacing is containergap.ToggleButton.Row
Children.count/Children.map/cloneElementthat injected first/middle/last border radii into each button by index.ToggleButtonRowContext: the row clips a single rounded container and memberToggleButtons read the context flag to flatten their own corners, with hairline dividers between segments. Wrapped or conditionally-renderedToggleButtons compose correctly.main— samecorner.extraSmallcontainer shape, samegetToggleButtonColorselected fill, same outline divider. This is a pure composition refactor, not a restyle.themeprop toToggleButton.Rowso it can resolve the theme itself instead of relying on injection.Appbar
React.Children.forEachcount heuristic, thedisplayName-based child filtering, the action splitting, and thecloneElementinjection ofcolor/mode/themeintoAppbar.Action/Appbar.BackAction/Appbar.Content.AppbarContext({ isDark, mode }). Children read it and derive their own foreground color and title variant, instead of having props spliced in.smallandcenter-alignedrender their children directly in author order; the title'sflex: 1keeps trailing actions right-aligned and the center-aligned title centers itself.mediumandlargekeep the two-row layout (controls row above a full-width title) by partitioning children for placement only — no props are injected.Appbar.BackActionbeforeAppbar.Contentand trailingAppbar.Actions after it, as shown in the docs. Children no longer receive injectedcolor/mode/themeprops; wrappingAppbar.Content/Appbar.Actionin another element keeps working because the values come from context.Out of scope (kept — idiomatic, not composition-hostile)
Tooltipsingle-child trigger clone,Chipavatar-prop clone,TouchableRipple/TouchableRipple.nativeChildren.onlyvalidation.Breaking changes (summary)
BREAKING CHANGE:
Appbarrenders children in author order (no automatic back-action-first reordering) and no longer injectscolor/mode/theme— the shared values come from context; orderAppbar.BackActionbeforeAppbar.Contentas in the docs.BREAKING CHANGE:
Card.Actionsno longer injectsmode/compact— passmode="outlined"/mode="contained"(andcompactif needed) explicitly.BREAKING CHANGE:
Dialog.Actionsno longer injectscompact/uppercase— set them explicitly.BREAKING CHANGE:
Card.Contentuses uniform vertical padding regardless of neighbors.Example migration (Card.Actions):
Test plan
yarn typecheck— passyarn lint— passList.Accordion(context indent),Dialog(surface top spacing, icon-to-title gap, no action-prop injection),Card(clip-to-shape, uniformCard.Contentpadding, noCard.Actionsinjection),ToggleButton.Row(segmented styling via context) andAppbar(context-provided values, author-order rendering). The fullyarn testrun has pre-existing RNAnimatedfake-timer baseline failures unrelated to this change — the identical set fails onmain.main) vs after, on iOS (iPhone 16 Pro) and Android (Pixel 9 Pro XL): Appbar (small / center-aligned / medium / large), List.Accordion, Dialog, Card, and ToggleButton.Row — no visual regressions. Screenshots below.Visual verification
Manual before (
main) vs after comparison on iOS (iPhone 16 Pro) and Android (Pixel 9 Pro XL) — no visual regressions.Appbar — all four modes (Android)
Appbar — small + medium two-row (iOS)
Appbar — baseline vs after vs pixel diff (iOS, small): identical apart from the status-bar clock
List.Accordion · Dialog · ToggleButton.Row · Card — baseline (
main) vs after (Android)ToggleButton.Row + Card — base vs HEAD, zero pixel delta (Android)
Related: #4989
Reference: #4963