Skip to content

fix(text): inherit parent styles in nested Text - #5040

Open
Doberjohn wants to merge 7 commits into
mainfrom
@Doberjohn/nested-text-inheritance
Open

Doberjohn wants to merge 7 commits into
mainfrom
@Doberjohn/nested-text-inheritance

Conversation

@Doberjohn

Copy link
Copy Markdown
Collaborator

Motivation

Two related bugs in nested Text, both reproduced on main first.

A variant parent lost its typography. When a Text with a variant wrapped a
nested Paper Text, it replaced its own font with the child's style instead of
layering on top of it. With an unstyled child there was no child style to take
over, so the parent rendered with no typography at all. This reached shipped
components: Chip renders <Text variant="labelLarge"> around its children, so
<Chip><Text>label</Text></Chip> dropped the entire label font block
(fontSize: 14, fontWeight: 500, lineHeight: 20, letterSpacing: 0.1).

A nested Text did not inherit. A nested Text or AnimatedText without a
variant applied its fallback font unconditionally, overriding the family,
weight, letter spacing and color it should have inherited from the enclosing text.

On the approach: React Native's Text already inherits from an enclosing Text
natively, so nothing needs to merge styles. The bug was Paper overriding what
would otherwise be inherited. A nested component now declares only its own
style. The shared context therefore carries a boolean rather than a style, and
lives in its own module so the Text and AnimatedText imports stay
one-directional.

AnimatedText was broken in both directions and is fixed too. It falls back to
bodyMedium where Text falls back to fonts.default, so an animated child
inside a displayLarge parent was snapping to fontSize: 14. That fallback
inconsistency between the two components still exists, but is no longer
reachable through nesting.

Related issue

Fixes #4351.

#4356 is an earlier attempt at the same issue, taking the opposite approach of
merging the parent's style into the child. It also only publishes the parent
style from the variant-less branch, so a child under a variant parent would
still not inherit, and it is written against a version of Text.tsx that no
longer exists on main.

Test plan

Example app, Typography screen, new "Nested text" section with four cases:
unstyled child, styled child, child with its own variant, and a bold parent
whose nested child inherits the weight.

  • 6 new unit tests. Each behaviour-changing one was confirmed to fail without its fix.
  • The 3 pre-existing nesting tests still pass, so the intentional
    child-overrides-parent behaviour is preserved.
  • AnimatedText had no test coverage before this; it now has 3 tests.
  • Full suite: 55 suites, 740 tests, 169 snapshots, with no snapshot churn.
  • yarn lint and yarn typecheck clean.

A nested Text or AnimatedText without a variant applied its fallback font unconditionally, overriding the font, weight, letter spacing and color it should have inherited from the enclosing text. React Native inherits these, so a nested one now declares only its own style. The shared context lives in its own module to keep the Text and AnimatedText imports one-directional. Fixes #4351.
@Doberjohn Doberjohn self-assigned this Aug 7, 2026
@Doberjohn Doberjohn changed the title @doberjohn/nested text inheritance Fix nested Text style inheritance Aug 7, 2026
@Doberjohn Doberjohn changed the title Fix nested Text style inheritance fix(text): inherit parent styles in nested Text Aug 7, 2026
@Doberjohn
Doberjohn requested a review from satya164 August 7, 2026 10:40
@Doberjohn
Doberjohn marked this pull request as ready for review August 7, 2026 10:40
@Doberjohn
Doberjohn requested a review from artus9033 August 7, 2026 10:44
@Doberjohn

Copy link
Copy Markdown
Collaborator Author

A quick note on the Typography work: because a nested Text without a variant no longer applies fonts.default at all, this fix is immune to Body variants switching typeface. Before it, that switch would have caused a visible sans-serif inside sans-serif-medium mismatch on Android. A nested Text that declares its own variant still applies it, which is unchanged here.

@MikitasK MikitasK 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.

overall, these changes looks good to me 👌 the approach looks solid & aligns with RN's text inheritance behavior 👍
just a few point to address before merge:

Comment thread src/components/__tests__/Typography/Text.test.tsx
Comment thread src/components/Typography/Text.tsx
Doberjohn and others added 2 commits August 13, 2026 17:04
Co-authored-by: Mikita Kliushun <nikita.kliushun@gmail.com>
Co-authored-by: Mikita Kliushun <nikita.kliushun@gmail.com>
@Doberjohn

Copy link
Copy Markdown
Collaborator Author

@MikitasK thanks for the review, your proposed changes sounded good!

@MikitasK MikitasK 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.

LGTM ✅

@JKobrynski JKobrynski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, good job!

@JKobrynski

Copy link
Copy Markdown
Collaborator

@satya164 @artus9033 all yours

@satya164 satya164 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please remove all LLM generated comments, they are either unnecessary or could be replaced with 1-2 lines describing the purpose.

only add comments where you think it's needed for understanding something and write it by hand.

for tests: add tests where

  • Text isn't a direct child and can be nested inside another component that's used inside Text
  • child text also sets its variant and theme

this implementation also isn't accurate to what react native does. a View nested inside Text will reset the inheritance, though it might be a reasonable limitation.

Comment on lines +39 to +41
// Anything longer is assumed to contain an element, since an array is an
// object itself.
return typeof children === 'object';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this doesn't make much sense. what makes children longer than 3 different?

Comment on lines +169 to +171
expect(screen.getByTestId('child-text')).not.toHaveStyle({
color: LightTheme.colors.onSurface,
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

shouldn't this inherit parent colors?

Comment on lines +189 to +197
it('text outside of another text still gets the default font', async () => {
await render(<Text testID="lone-text">{content}</Text>);
const { fontFamily, fontWeight } = LightTheme.fonts.default;

expect(screen.getByTestId('lone-text')).toHaveStyle({
fontFamily,
fontWeight,
});
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

doesn't this duplicate "renders v3 Text component without variant..."?

Comment on lines +174 to +187
it('nested text keeps applying its own style while inheriting the rest', async () => {
await render(
<Text variant="displayLarge">
Parent{' '}
<Text testID="child-text" style={{ fontStyle: 'italic' }}>
child
</Text>
</Text>
);

expect(screen.getByTestId('child-text')).toHaveStyle({
fontStyle: 'italic',
});
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this doesn't assert what everything the title says. it should just be consolidated into the above test

Comment on lines +199 to +232
it('text nested in animated text inherits instead of resetting', async () => {
await render(
<AnimatedText variant="displayLarge">
Parent <Text testID="child-text">child</Text>
</AnimatedText>
);
const { fontFamily, fontWeight } = LightTheme.fonts.default;

expect(screen.getByTestId('child-text')).not.toHaveStyle({ fontFamily });
expect(screen.getByTestId('child-text')).not.toHaveStyle({ fontWeight });
});

it('animated text nested in text inherits instead of resetting', async () => {
await render(
<Text variant="displayLarge">
Parent <AnimatedText testID="child-animated">child</AnimatedText>
</Text>
);
// `AnimatedText` falls back to `bodyMedium` rather than the default font.
const { fontFamily, fontSize } = LightTheme.fonts.bodyMedium;

expect(screen.getByTestId('child-animated')).not.toHaveStyle({ fontFamily });
expect(screen.getByTestId('child-animated')).not.toHaveStyle({ fontSize });
});

it('animated text outside of any text still gets its fallback font', async () => {
await render(<AnimatedText testID="lone-animated">{content}</AnimatedText>);
const { fontFamily, fontSize } = LightTheme.fonts.bodyMedium;

expect(screen.getByTestId('lone-animated')).toHaveStyle({
fontFamily,
fontSize,
});
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not sure we need separate tests for animated text.

@satya164
satya164 requested a balanced review from Copilot September 15, 2026 20:33

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@satya164

Copy link
Copy Markdown
Member

Review by Claude

Screenshots of this code added to the Typography screen, dark theme:

<Text variant="headlineSmall" style={{ color: 'tomato' }}>
  A tomato <Text>plain child</Text>
</Text>
<Text variant="headlineSmall" style={{ color: 'tomato' }}>
  B tomato <Text variant="bodyLarge">variant child</Text>
</Text>
<Text style={{ color: 'tomato', fontWeight: 'bold' }}>
  C tomato bold <Text variant="titleLarge">variant child</Text>
</Text>
<Text variant="headlineSmall">
  D parent <Text theme={{ colors: { onSurface: 'green' } }}>green theme child</Text>
</Text>
<Chip selected onPress={() => {}}><Text variant="labelLarge">Var</Text></Chip>
<Button mode="contained"><Text variant="labelLarge">Var</Text></Button>

iOS

iOS, dark theme: nested Text with a variant or a theme

  • Nested Text with a variant drops the parent color. Steps: render B, C and the "Var" button above. What happens: the children render in onSurface, and the "Var" button label is almost invisible on the primary container. Expected: B and C stay tomato, and the button label keeps the button label color. (via Claude, verify if it makes sense)
  • Nested Text without a variant ignores its theme prop. Steps: render D above. What happens: the child renders in the parent color. Expected: the child renders green, as it does on main. (via Claude, verify if it makes sense)

Android

Android, dark theme: nested Text with a variant or a theme

  • Nested Text with a variant drops the parent color. Same steps and result as on iOS. (via Claude, verify if it makes sense)
  • Nested Text without a variant ignores its theme prop. Same steps and result as on iOS. (via Claude, verify if it makes sense)

Web

Web, dark theme: nested Text with a variant or a theme

  • Nested Text with a variant drops the parent color. Same steps and result as on iOS. (via Claude, verify if it makes sense)
  • Nested Text without a variant ignores its theme prop. Same steps and result as on iOS. (via Claude, verify if it makes sense)

@satya164 satya164 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review by Claude

See the inline comments. (via Claude, verify if it makes sense)

}

return (
element = (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A nested Text with a variant still sets color: theme.colors.onSurface (and writingDirection and textAlign) on line 155, so it drops the color of the enclosing text. #4351 covers this case too: the child sets its own variant and should still inherit the rest.

For example, <Button mode="contained"><Text variant="labelLarge">Save</Text></Button> renders the label in onSurface on the primary container. In dark theme the label is almost invisible (the "Var" button):

iOS, dark theme: nested Text with a variant

When isNested is true, apply only the variant font:

style={
  isNested
    ? textStyle
    : [
        styles.text,
        { writingDirection, color: theme.colors.onSurface },
        textStyle,
      ]
}

This keeps the style array of a top-level Text the same, so the snapshots do not change. AnimatedText has the same variant branch and needs the same change. (via Claude, verify if it makes sense)

{...rest}
/>
);
} else if (isNested) {

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This branch also ignores the theme prop. On main, <Text variant="headlineSmall">Parent <Text theme={{ colors: { onSurface: "green" } }}>child</Text></Text> renders the child green. With this change it renders in the parent color (case D in the screenshot comment). themeOverrides in AnimatedText has the same problem. Skip the defaults only when no theme override is passed:

Suggested change
} else if (isNested) {
} else if (isNested && !initialTheme) {

(via Claude, verify if it makes sense)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nested Text components do not inherit style

5 participants