Skip to content
Open
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
31 changes: 31 additions & 0 deletions example/src/Examples/TextExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ const TextExample = () => {
Body Small
</Text>

<Text style={styles.heading} variant="titleMedium">
Nested text
</Text>

<Text style={styles.text} variant="headlineSmall">
<Text>Unstyled child, stays Headline Small</Text>
</Text>
<Text style={styles.text} variant="headlineSmall">
<Text style={styles.nestedChild}>
Styled child, italic but still Headline Small
</Text>
</Text>
<Text style={styles.text} variant="headlineSmall">
<Text variant="bodySmall">
Child variant wins, renders Body Small
</Text>
</Text>
<Text style={[styles.text, styles.boldParent]} variant="headlineSmall">
Bold parent, <Text>and the nested child inherits the weight</Text>
</Text>

<PaperProvider theme={theme}>
<Text style={styles.text} variant="customVariant">
Custom Variant
Expand All @@ -104,6 +125,16 @@ const styles = StyleSheet.create({
text: {
marginVertical: 4,
},
heading: {
marginTop: 24,
marginBottom: 4,
},
nestedChild: {
fontStyle: 'italic',
},
boldParent: {
fontWeight: 'bold',
},
});

export default TextExample;
22 changes: 20 additions & 2 deletions src/components/Typography/AnimatedText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { Animated, StyleSheet, Text } from 'react-native';
import type { StyleProp, TextProps, TextStyle } from 'react-native';

import { canContainNestedText, NestedTextContext } from './NestedTextContext';
import type { VariantProp } from './types';
import { useLocale } from '../../core/locale';
import { useInternalTheme } from '../../core/theming';
Expand Down Expand Up @@ -46,6 +47,9 @@ function AnimatedText({
}: Props<never>) {
const theme = useInternalTheme(themeOverrides);
const { direction: writingDirection } = useLocale();
const isNested = React.useContext(NestedTextContext);

let element: React.ReactElement;

if (variant) {
const font = theme.fonts[variant];
Expand All @@ -57,7 +61,7 @@ function AnimatedText({
);
}

return (
element = (
<Animated.Text
ref={ref}
{...rest}
Expand All @@ -69,13 +73,17 @@ function AnimatedText({
]}
/>
);
} else if (isNested) {
// Declare only what this component was asked for. Everything else, including
// the font and the color, inherits from the enclosing text.
element = <Animated.Text ref={ref} {...rest} style={style} />;
} else {
const font = theme.fonts.bodyMedium;
const textStyle = {
...font,
color: theme.colors.onSurface,
};
return (
element = (
<Animated.Text
ref={ref}
{...rest}
Expand All @@ -90,6 +98,16 @@ function AnimatedText({
/>
);
}

if (!canContainNestedText(rest.children)) {
return element;
}

return (
<NestedTextContext.Provider value={true}>
{element}
</NestedTextContext.Provider>
);
}

const styles = StyleSheet.create({
Expand Down
42 changes: 42 additions & 0 deletions src/components/Typography/NestedTextContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as React from 'react';

/**
* Tells a `Text` or `AnimatedText` that it is rendered inside another one.
*
* React Native's `Text` inherits the resolved style of an enclosing `Text`, so a
* nested one only has to declare what it wants to change. Applying the default
* font here regardless would overwrite everything it should have inherited, so a
* nested component without a `variant` leaves those properties unset instead.
*
* Lives in its own module because `Text` renders `AnimatedText` in its nesting
* checks, so importing the context from either of them would form a cycle.
*/
export const NestedTextContext = React.createContext(false);

/**
* Whether `children` can hold a nested `Text`, so that the provider above is
* only rendered when something is there to consume it.
*
* Mirrors the check React Native runs before wrapping its own text ancestor
* context, down to the array cutoff. The point of the check is to save the
* provider's overhead, so it has to stay cheaper than what it saves: three
* children covers the common cases without walking a long list on every render.
*
* Takes `unknown` rather than `ReactNode` because `AnimatedText` widens its
* `children` with animated values, and this check only looks at the shape.
*/
export const canContainNestedText = (children: unknown) => {
if (children == null) {
return false;
}

if (Array.isArray(children) && children.length <= 3) {
return children.some(
(child: unknown) => child != null && typeof child === 'object'
);
}

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

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?

};
32 changes: 27 additions & 5 deletions src/components/Typography/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { StyleSheet, Text as NativeText } from 'react-native';
import type { StyleProp, TextStyle } from 'react-native';

import AnimatedText from './AnimatedText';
import { canContainNestedText, NestedTextContext } from './NestedTextContext';
import type { VariantProp } from './types';
import { useLocale } from '../../core/locale';
import { useInternalTheme } from '../../core/theming';
Expand Down Expand Up @@ -87,11 +88,14 @@ const Text = ({
// FIXME: destructure it in TS 4.6+
const theme = useInternalTheme(initialTheme);
const { direction: writingDirection } = useLocale();
const isNested = React.useContext(NestedTextContext);

React.useImperativeHandle(ref, () => ({
setNativeProps: (args: Object) => root.current?.setNativeProps(args),
}));

let element: React.ReactElement;

if (variant) {
let font = theme.fonts[variant];
let textStyle = [font, style];
Expand Down Expand Up @@ -124,10 +128,14 @@ const Text = ({
// <Chip>
// <Text style={{fontSize: 30}}>Nested</Text>
// </Chip>
// Solution: To address the following scenario, the code below overrides the
// parent's style with children's style:
// Solution: To address the following scenario, the code below lets the
// children's style win over the parent's, while keeping the
// parent's `variant` as the base. Dropping the base instead
// would leave a parent wrapping an unstyled `Text` with no
// typography at all, since there is no child style to take over
// from it.
if (!props.variant) {
textStyle = [style, props.style];
textStyle = [font, style, props.style];
}
}

Expand All @@ -139,7 +147,7 @@ const Text = ({
);
}

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)

<NativeText
ref={root}
style={[
Expand All @@ -150,20 +158,34 @@ const Text = ({
{...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)

// Declare only what this `Text` was asked for. Everything else, including
// the font and the color, inherits from the enclosing `Text`.
element = <NativeText {...rest} ref={root} style={style} />;
} else {
const font = theme.fonts.default;
const textStyle = {
...font,
color: theme.colors?.onSurface,
};
return (
element = (
<NativeText
{...rest}
ref={root}
style={[styles.text, textStyle, { writingDirection }, style]}
/>
);
}

if (!canContainNestedText(rest.children)) {
return element;
}

return (
<NestedTextContext.Provider value={true}>
{element}
</NestedTextContext.Provider>
);
Comment thread
Doberjohn marked this conversation as resolved.
};

const styles = StyleSheet.create({
Expand Down
115 changes: 115 additions & 0 deletions src/components/__tests__/Typography/Text.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { render, screen } from '../../../test-utils';
import configureFonts from '../../../theme/fonts';
import { LightTheme } from '../../../theme/schemes';
import { tokens } from '../../../theme/tokens';
import AnimatedText from '../../Typography/AnimatedText';
import Text, { customText } from '../../Typography/Text';

const content = 'Something rendered as a child content';
Expand Down Expand Up @@ -116,6 +117,120 @@ it("nested text without variant, but with styles, should override parent's style
expect(screen.getByTestId('parent-text')).toHaveStyle(customStyle);
});

it("nested unstyled text should leave the parent's variant intact", async () => {
await render(
<Text testID="parent-text" variant="displayLarge">
<Text>Test</Text>
</Text>
);

expect(screen.getByTestId('parent-text')).toHaveStyle(
LightTheme.fonts.displayLarge
);
});

it("nested styled text should only override the parent's clashing properties", async () => {
await render(
<Text testID="parent-text" variant="displayLarge">
<Text style={{ fontSize: 50 }}>Test</Text>
</Text>
);

expect(screen.getByTestId('parent-text')).toHaveStyle({
// The child wins where the two overlap,
fontSize: 50,
// but the rest of the parent's variant survives.
letterSpacing: LightTheme.fonts.displayLarge.letterSpacing,
lineHeight: LightTheme.fonts.displayLarge.lineHeight,
});
});

it('nested text alongside other content inherits instead of resetting', async () => {
const parentColor = 'tomato';

await render(
<Text
testID="parent-text"
variant="displayLarge"
style={{ color: parentColor }}
>
Parent <Text testID="child-text">child</Text>
</Text>
);

const { fontFamily, fontWeight, letterSpacing } = LightTheme.fonts.default;

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

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 thread
Doberjohn marked this conversation as resolved.

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',
});
});
Comment on lines +174 to +187

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


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,
});
});
Comment on lines +189 to +197

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..."?


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,
});
});
Comment on lines +199 to +232

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.


it('throws when custom variant not provided', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});

Expand Down