Skip to content

refactor: replace React.Children usage to more composition-friendly alternatives (#4989)#5018

Open
matkoson wants to merge 11 commits into
callstack:mainfrom
matkoson:refactor/react-children
Open

refactor: replace React.Children usage to more composition-friendly alternatives (#4989)#5018
matkoson wants to merge 11 commits into
callstack:mainfrom
matkoson:refactor/react-children

Conversation

@matkoson

@matkoson matkoson commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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

  • Removed the React.Children.map + cloneElement that injected paddingLeft and theme into expanded children.
  • Added ListAccordionContext ({ leftIndent }). List.Item consumes it and indents its own container when it renders no left/right. Ripple/background stay full-width.
  • Wrapped children now keep the indentation behavior. Output preserved for List.Item children (zero snapshot churn).

Dialog

  • Removed the first-child cloneElement that injected marginTop: 24.
  • The Dialog surface owns the top inset (paddingTop: 24); Dialog.Title and Dialog.Icon no longer add their own top offset. Dialog.Icon keeps a 16px bottom gap so icon-to-title spacing is preserved.

Dialog.Actions

  • BREAKING CHANGE: no longer injects compact and uppercase into action buttons. Set them explicitly on each button. Inter-action spacing is now container gap.

Card

  • Removed child counting, sibling displayName scanning, and the per-child cloneElement injection of index / total / siblings / borderRadiusStyles.
  • The card clips inner content to its shape (overflow: hidden + combined border radius) instead of injecting corner styles into Card.Cover / Card.Title.
  • BREAKING CHANGE: Card.Content now uses uniform vertical padding regardless of neighboring sections.

Card.Actions

  • BREAKING CHANGE: no longer auto-assigns mode (previously outlined for the first action, contained for the rest) or compact. Set button props explicitly. Spacing is container gap.

ToggleButton.Row

  • Removed the positional Children.count / Children.map / cloneElement that injected first/middle/last border radii into each button by index.
  • Segmented styling now flows through ToggleButtonRowContext: the row clips a single rounded container and member ToggleButtons read the context flag to flatten their own corners, with hairline dividers between segments. Wrapped or conditionally-rendered ToggleButtons compose correctly.
  • Visual output is unchanged from main — same corner.extraSmall container shape, same getToggleButtonColor selected fill, same outline divider. This is a pure composition refactor, not a restyle.
  • Added an optional theme prop to ToggleButton.Row so it can resolve the theme itself instead of relying on injection.

Appbar

  • Removed the React.Children.forEach count heuristic, the displayName-based child filtering, the action splitting, and the cloneElement injection of color / mode / theme into Appbar.Action / Appbar.BackAction / Appbar.Content.
  • Added AppbarContext ({ isDark, mode }). Children read it and derive their own foreground color and title variant, instead of having props spliced in.
  • 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.
  • BREAKING CHANGE: Appbar children now render 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.Actions 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.

Out of scope (kept — idiomatic, not composition-hostile)

  • Tooltip single-child trigger clone, Chip avatar-prop clone, TouchableRipple / TouchableRipple.native Children.only validation.

Breaking changes (summary)

BREAKING CHANGE: Appbar renders children in author order (no automatic back-action-first reordering) and no longer injects color/mode/theme — the shared values come from context; order Appbar.BackAction before Appbar.Content as in the docs.
BREAKING CHANGE: Card.Actions no longer injects mode/compact — pass mode="outlined"/mode="contained" (and compact if needed) explicitly.
BREAKING CHANGE: Dialog.Actions no longer injects compact/uppercase — set them explicitly.
BREAKING CHANGE: Card.Content uses uniform vertical padding regardless of neighbors.

Example migration (Card.Actions):

// before
<Card.Actions>
  <Button>Cancel</Button>
  <Button>Ok</Button>
</Card.Actions>

// after
<Card.Actions>
  <Button mode="outlined">Cancel</Button>
  <Button mode="contained">Ok</Button>
</Card.Actions>

Test plan

  • yarn typecheck — pass
  • yarn lint — pass
  • Unit tests: scoped suites are green, with new characterization tests for List.Accordion (context indent), Dialog (surface top spacing, icon-to-title gap, no action-prop injection), Card (clip-to-shape, uniform Card.Content padding, no Card.Actions injection), ToggleButton.Row (segmented styling via context) and Appbar (context-provided values, author-order rendering). The full yarn test run has pre-existing RN Animated fake-timer baseline failures unrelated to this change — the identical set fails on main.
  • Manual visual inspection, before (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 / large / center-aligned

Appbar — small + medium two-row (iOS)

Appbar small and medium on iOS

Appbar — baseline vs after vs pixel diff (iOS, small): identical apart from the status-bar clock

Appbar baseline vs after vs diff

List.Accordion · Dialog · ToggleButton.Row · Card — baseline (main) vs after (Android)

Composition components baseline vs after

ToggleButton.Row + Card — base vs HEAD, zero pixel delta (Android)

ToggleButton and Card base vs HEAD, pixel-transparent

Related: #4989
Reference: #4963

…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.
@matkoson matkoson changed the title refactor: replace React.Children composition-hostile APIs (#4989) refactor: replace React.Children usage to more composition-friendly alternatives (#4989) Jun 30, 2026
matkoson added 9 commits June 30, 2026 18:38
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.
@matkoson matkoson marked this pull request as ready for review July 2, 2026 10:10
Copilot AI review requested due to automatic review settings July 2, 2026 10:10
@matkoson matkoson force-pushed the refactor/react-children branch from 9a9da84 to b3d0b92 Compare July 2, 2026 10:10
@matkoson matkoson marked this pull request as draft July 2, 2026 10:11
@matkoson matkoson marked this pull request as ready for review July 2, 2026 10:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.Row to use context-driven segmented styling (no positional cloning) and adds optional theming.
  • Updates List.Accordion indentation behavior via context so wrapped List.Item children indent correctly without prop injection.
  • Refactors Dialog, Dialog.Actions, and Card sections 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).

Comment thread src/components/Dialog/DialogActions.tsx Outdated
Comment on lines 50 to 56
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>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 0d08f46DialogActions 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.

Comment on lines +68 to +72
<ToggleButtonRowContext.Provider value={SEGMENTED_ROW_CONTEXT}>
<View style={style}>
<View
style={[
styles.row,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines 90 to 96
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,
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copilot AI review requested due to automatic review settings July 2, 2026 10:17
@matkoson matkoson marked this pull request as draft July 2, 2026 10:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 4 comments.

Comment on lines +69 to +81
<View style={style}>
<View
style={[
styles.row,
{
backgroundColor: outlineColor,
borderRadius,
},
]}
>
{children}
</View>
</View>
Comment on lines 91 to +95
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,
Comment on lines 55 to 60
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
padding: 8,
},
Comment on lines 63 to 71
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
@matkoson matkoson marked this pull request as ready for review July 2, 2026 10:39
Copilot AI review requested due to automatic review settings July 2, 2026 10:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 3 comments.

Comment on lines 55 to 60
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
padding: 8,
},
Comment on lines +194 to +202
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
);
Comment on lines 72 to 74
justifyContent: 'flex-end',
gap: 8,
paddingBottom: 24,
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