From 71bb6ce2fe8c1084697e8bfefdb3e0e54f026430 Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Fri, 7 Aug 2026 11:49:19 +0300 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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; From cda2eeec1bfe6d23bdac207674baeff24f18c200 Mon Sep 17 00:00:00 2001 From: John Fanidis Date: Thu, 13 Aug 2026 17:04:42 +0300 Subject: [PATCH 5/7] Update src/components/__tests__/Typography/Text.test.tsx Co-authored-by: Mikita Kliushun --- .../__tests__/Typography/Text.test.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/__tests__/Typography/Text.test.tsx b/src/components/__tests__/Typography/Text.test.tsx index 56196f8451..0ae333cc80 100644 --- a/src/components/__tests__/Typography/Text.test.tsx +++ b/src/components/__tests__/Typography/Text.test.tsx @@ -146,19 +146,29 @@ it("nested styled text should only override the parent's clashing properties", a }); it('nested text alongside other content inherits instead of resetting', async () => { + const parentColor = 'tomato'; + 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('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, + }); }); it('nested text keeps applying its own style while inheriting the rest', async () => { From 8b2cb9d788d7d4303676fc561f38ca4c9c8066ad Mon Sep 17 00:00:00 2001 From: John Fanidis Date: Thu, 13 Aug 2026 17:05:44 +0300 Subject: [PATCH 6/7] Update src/components/Typography/Text.tsx Co-authored-by: Mikita Kliushun --- src/components/Typography/Text.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/Typography/Text.tsx b/src/components/Typography/Text.tsx index 6138e263a8..2ff9ead625 100644 --- a/src/components/Typography/Text.tsx +++ b/src/components/Typography/Text.tsx @@ -177,6 +177,10 @@ const Text = ({ ); } + if (!canContainNestedText(rest.children)) { + return element; + } + return ( {element} From adb1f93530ab965d32d272b9e889f00f44e3673d Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Thu, 13 Aug 2026 18:58:43 +0300 Subject: [PATCH 7/7] perf(text): skip the nesting context when children cannot contain text --- src/components/Typography/AnimatedText.tsx | 6 +++- .../Typography/NestedTextContext.tsx | 28 +++++++++++++++++++ src/components/Typography/Text.tsx | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/Typography/AnimatedText.tsx b/src/components/Typography/AnimatedText.tsx index c517fbe715..cee328b15e 100644 --- a/src/components/Typography/AnimatedText.tsx +++ b/src/components/Typography/AnimatedText.tsx @@ -3,7 +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 { canContainNestedText, NestedTextContext } from './NestedTextContext'; import type { VariantProp } from './types'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; @@ -99,6 +99,10 @@ function AnimatedText({ ); } + if (!canContainNestedText(rest.children)) { + return element; + } + return ( {element} diff --git a/src/components/Typography/NestedTextContext.tsx b/src/components/Typography/NestedTextContext.tsx index a337203be0..b42c718d0c 100644 --- a/src/components/Typography/NestedTextContext.tsx +++ b/src/components/Typography/NestedTextContext.tsx @@ -12,3 +12,31 @@ import * as React from 'react'; * 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'; +}; diff --git a/src/components/Typography/Text.tsx b/src/components/Typography/Text.tsx index 2ff9ead625..007b8b67ff 100644 --- a/src/components/Typography/Text.tsx +++ b/src/components/Typography/Text.tsx @@ -4,7 +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 { canContainNestedText, NestedTextContext } from './NestedTextContext'; import type { VariantProp } from './types'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming';