From 71bb6ce2fe8c1084697e8bfefdb3e0e54f026430 Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Fri, 7 Aug 2026 11:49:19 +0300 Subject: [PATCH 1/4] fix(text): keep the parent variant when nesting Text --- src/components/Typography/Text.tsx | 10 +++++-- .../__tests__/Typography/Text.test.tsx | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/components/Typography/Text.tsx b/src/components/Typography/Text.tsx index df957411f9..54e4cb8a6b 100644 --- a/src/components/Typography/Text.tsx +++ b/src/components/Typography/Text.tsx @@ -124,10 +124,14 @@ const Text = ({ // // Nested // - // 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]; } } diff --git a/src/components/__tests__/Typography/Text.test.tsx b/src/components/__tests__/Typography/Text.test.tsx index b470f47f32..c6b887d71f 100644 --- a/src/components/__tests__/Typography/Text.test.tsx +++ b/src/components/__tests__/Typography/Text.test.tsx @@ -116,6 +116,34 @@ 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( + + Test + + ); + + expect(screen.getByTestId('parent-text')).toHaveStyle( + LightTheme.fonts.displayLarge + ); +}); + +it("nested styled text should only override the parent's clashing properties", async () => { + await render( + + Test + + ); + + 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('throws when custom variant not provided', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); From 6e1fe71f7d9a9b035a754e4582071d9cc5c4c366 Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Fri, 7 Aug 2026 12:30:49 +0300 Subject: [PATCH 2/4] docs(text): add nested Text example cases --- example/src/Examples/TextExample.tsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/example/src/Examples/TextExample.tsx b/example/src/Examples/TextExample.tsx index 18f1eb8b84..dcf666bfd7 100644 --- a/example/src/Examples/TextExample.tsx +++ b/example/src/Examples/TextExample.tsx @@ -85,6 +85,24 @@ const TextExample = () => { Body Small + + Nested text + + + + Unstyled child, stays Headline Small + + + + Styled child, italic but still Headline Small + + + + + Child variant wins, renders Body Small + + + Custom Variant @@ -104,6 +122,13 @@ const styles = StyleSheet.create({ text: { marginVertical: 4, }, + heading: { + marginTop: 24, + marginBottom: 4, + }, + nestedChild: { + fontStyle: 'italic', + }, }); export default TextExample; From c26b3c1274f43bcc7ff58ea2fc9d0c17c9197981 Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Fri, 7 Aug 2026 13:17:36 +0300 Subject: [PATCH 3/4] fix(text): inherit the parent style in nested Text 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. --- src/components/Typography/AnimatedText.tsx | 18 ++++- .../Typography/NestedTextContext.tsx | 14 ++++ src/components/Typography/Text.tsx | 18 ++++- .../__tests__/Typography/Text.test.tsx | 77 +++++++++++++++++++ 4 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 src/components/Typography/NestedTextContext.tsx diff --git a/src/components/Typography/AnimatedText.tsx b/src/components/Typography/AnimatedText.tsx index ea47c43b58..c517fbe715 100644 --- a/src/components/Typography/AnimatedText.tsx +++ b/src/components/Typography/AnimatedText.tsx @@ -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 { NestedTextContext } from './NestedTextContext'; import type { VariantProp } from './types'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; @@ -46,6 +47,9 @@ function AnimatedText({ }: Props) { const theme = useInternalTheme(themeOverrides); const { direction: writingDirection } = useLocale(); + const isNested = React.useContext(NestedTextContext); + + let element: React.ReactElement; if (variant) { const font = theme.fonts[variant]; @@ -57,7 +61,7 @@ function AnimatedText({ ); } - return ( + element = ( ); + } 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 = ; } else { const font = theme.fonts.bodyMedium; const textStyle = { ...font, color: theme.colors.onSurface, }; - return ( + element = ( ); } + + return ( + + {element} + + ); } const styles = StyleSheet.create({ diff --git a/src/components/Typography/NestedTextContext.tsx b/src/components/Typography/NestedTextContext.tsx new file mode 100644 index 0000000000..a337203be0 --- /dev/null +++ b/src/components/Typography/NestedTextContext.tsx @@ -0,0 +1,14 @@ +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); diff --git a/src/components/Typography/Text.tsx b/src/components/Typography/Text.tsx index 54e4cb8a6b..6138e263a8 100644 --- a/src/components/Typography/Text.tsx +++ b/src/components/Typography/Text.tsx @@ -4,6 +4,7 @@ import { StyleSheet, Text as NativeText } from 'react-native'; import type { StyleProp, TextStyle } from 'react-native'; import AnimatedText from './AnimatedText'; +import { NestedTextContext } from './NestedTextContext'; import type { VariantProp } from './types'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; @@ -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]; @@ -143,7 +147,7 @@ const Text = ({ ); } - return ( + element = ( ); + } else if (isNested) { + // Declare only what this `Text` was asked for. Everything else, including + // the font and the color, inherits from the enclosing `Text`. + element = ; } else { const font = theme.fonts.default; const textStyle = { ...font, color: theme.colors?.onSurface, }; - return ( + element = ( ); } + + return ( + + {element} + + ); }; const styles = StyleSheet.create({ diff --git a/src/components/__tests__/Typography/Text.test.tsx b/src/components/__tests__/Typography/Text.test.tsx index c6b887d71f..56196f8451 100644 --- a/src/components/__tests__/Typography/Text.test.tsx +++ b/src/components/__tests__/Typography/Text.test.tsx @@ -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'; @@ -144,6 +145,82 @@ it("nested styled text should only override the parent's clashing properties", a }); }); +it('nested text alongside other content inherits instead of resetting', async () => { + await render( + + Parent child + + ); + + // React Native's `Text` inherits from the enclosing `Text`, so the child must + // not restate the default font, which would override what it inherits. + const { fontFamily, fontWeight, letterSpacing } = LightTheme.fonts.default; + + expect(screen.getByTestId('child-text')).not.toHaveStyle({ fontFamily }); + expect(screen.getByTestId('child-text')).not.toHaveStyle({ fontWeight }); + expect(screen.getByTestId('child-text')).not.toHaveStyle({ letterSpacing }); +}); + +it('nested text keeps applying its own style while inheriting the rest', async () => { + await render( + + Parent{' '} + + child + + + ); + + expect(screen.getByTestId('child-text')).toHaveStyle({ + fontStyle: 'italic', + }); +}); + +it('text outside of another text still gets the default font', async () => { + await render({content}); + const { fontFamily, fontWeight } = LightTheme.fonts.default; + + expect(screen.getByTestId('lone-text')).toHaveStyle({ + fontFamily, + fontWeight, + }); +}); + +it('text nested in animated text inherits instead of resetting', async () => { + await render( + + Parent child + + ); + 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( + + Parent child + + ); + // `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({content}); + const { fontFamily, fontSize } = LightTheme.fonts.bodyMedium; + + expect(screen.getByTestId('lone-animated')).toHaveStyle({ + fontFamily, + fontSize, + }); +}); + it('throws when custom variant not provided', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); From 3af3142c389a6e9b56ab30e35fe25a750b7aea21 Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Fri, 7 Aug 2026 13:23:01 +0300 Subject: [PATCH 4/4] docs(text): show inherited weight in the nested Text example --- example/src/Examples/TextExample.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/example/src/Examples/TextExample.tsx b/example/src/Examples/TextExample.tsx index dcf666bfd7..7eb5a71796 100644 --- a/example/src/Examples/TextExample.tsx +++ b/example/src/Examples/TextExample.tsx @@ -102,6 +102,9 @@ const TextExample = () => { Child variant wins, renders Body Small + + Bold parent, and the nested child inherits the weight + @@ -129,6 +132,9 @@ const styles = StyleSheet.create({ nestedChild: { fontStyle: 'italic', }, + boldParent: { + fontWeight: 'bold', + }, }); export default TextExample;