From 6bf6f5135e5c5240467e5957cb4107ed11b62da2 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 10 Apr 2026 15:50:54 -0700 Subject: [PATCH 1/5] fix(macOS): TextInput text color not adapting to appearance changes on new architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the new architecture (Fabric), TextInput text color was correct on initial mount but didn't update when the system appearance changed (light ↔ dark mode). Two root causes: 1. RCTNSTextAttributesFromTextAttributes skipped setting NSForegroundColorAttributeName when no explicit color/opacity was set. On macOS, NSAttributedString defaults to black (unlike iOS), making text invisible in dark mode. 2. RCTTextInputComponentView had no appearance change handler on macOS (viewDidChangeEffectiveAppearance), so defaultTextAttributes were never refreshed. Additionally, the C++ color pipeline resolves dynamic colors (like labelColor) to static values at creation time, so simply re-calling the attribute builder returns stale colors. The fix detects the default foreground color and replaces it with a fresh dynamic NSColor.labelColor, then re-applies the attributed text while suppressing state reconciliation to prevent React from overwriting the update with cached shadow tree values. Co-Authored-By: Claude Opus 4.6 --- .../TextInput/RCTTextInputComponentView.mm | 55 +++++++++++++++++-- .../RCTAttributedTextUtils.mm | 10 ++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index 129440f4db66..34bee5db3ab0 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -9,6 +9,7 @@ #import #import +#import // [macOS] #import #import @@ -169,6 +170,44 @@ - (void)didMoveToWindow [self _restoreTextSelection]; } +// [macOS +- (void)_updateDefaultTextAttributes +{ + const auto &props = static_cast(*_props); + NSMutableDictionary *attrs = + RCTNSTextAttributesFromTextAttributes(props.getEffectiveTextAttributes(RCTFontSizeMultiplier())); + +#if TARGET_OS_OSX + // The C++ color pipeline resolves dynamic colors (like labelColor) to static + // values at creation time, so re-calling RCTNSTextAttributesFromTextAttributes + // after an appearance change returns the same stale color. When the foreground + // color is the default (semantic labelColor, not a user-specified color), + // replace it with a fresh dynamic NSColor.labelColor so the text adapts to the + // current appearance. Explicit colors (e.g. "white", "red") are left as-is. + const auto &effectiveAttrs = props.getEffectiveTextAttributes(RCTFontSizeMultiplier()); + facebook::react::SharedColor defaultColor = facebook::react::defaultForegroundTextColor(); + if (!effectiveAttrs.foregroundColor || *effectiveAttrs.foregroundColor == *defaultColor) { + attrs[NSForegroundColorAttributeName] = [NSColor labelColor]; + } +#endif + + _backedTextInputView.defaultTextAttributes = attrs; + + // Also update the existing attributed text so the visible text re-renders + // with the new color (defaultTextAttributes only affects newly typed text). + // Wrap in _comingFromJS to prevent textInputDidChange from pushing a state + // update back to the shadow tree, which would overwrite our fresh colors + // with the stale cached attributed string. + NSString *currentText = _backedTextInputView.attributedText.string; + if (currentText.length > 0) { + NSAttributedString *updated = [[NSAttributedString alloc] initWithString:currentText attributes:attrs]; + _comingFromJS = YES; + _backedTextInputView.attributedText = updated; + _comingFromJS = NO; + } +} +// macOS] + #if !TARGET_OS_OSX // [macOS] // TODO: replace with registerForTraitChanges once iOS 17.0 is the lowest supported version - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection @@ -178,12 +217,20 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection if (facebook::react::ReactNativeFeatureFlags::enableFontScaleChangesUpdatingLayout() && UITraitCollection.currentTraitCollection.preferredContentSizeCategory != previousTraitCollection.preferredContentSizeCategory) { - const auto &newTextInputProps = static_cast(*_props); - _backedTextInputView.defaultTextAttributes = - RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier())); + [self _updateDefaultTextAttributes]; } + + if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { // [macOS] + [self _updateDefaultTextAttributes]; // [macOS] + } // [macOS] +} +#else // [macOS +- (void)viewDidChangeEffectiveAppearance +{ + [super viewDidChangeEffectiveAppearance]; + [self _updateDefaultTextAttributes]; } -#endif // [macOS] +#endif // macOS] - (void)reactUpdateResponderOffsetForScrollView:(RCTScrollViewComponentView *)scrollView { diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 207a5f044e0e..4c4097e7f260 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -183,6 +183,16 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex if (textAttributes.foregroundColor || !isnan(textAttributes.opacity)) { attributes[NSForegroundColorAttributeName] = effectiveForegroundColor; } +#if TARGET_OS_OSX // [macOS + // On macOS, NSAttributedString defaults to black when no foreground color + // attribute is present, unlike iOS where the text view provides its own + // default. Always set the foreground color so that text is visible in both + // light and dark mode. RCTEffectiveForegroundColorFromTextAttributes falls + // back to the platform's dynamic labelColor when no color is specified. + else { + attributes[NSForegroundColorAttributeName] = effectiveForegroundColor; + } +#endif // macOS] if (textAttributes.backgroundColor || !isnan(textAttributes.opacity)) { attributes[NSBackgroundColorAttributeName] = RCTEffectiveBackgroundColorFromTextAttributes(textAttributes); From 7144f69479bf8ea5245069b131abec06113276c3 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 10 Apr 2026 16:14:45 -0700 Subject: [PATCH 2/5] fix(macOS): simplify TextInput appearance change handler Remove unnecessary [NSColor labelColor] override - the C++ color pipeline already preserves dynamic NSColor objects through wrapManagedObject/unwrapManagedObject, so re-applying text attributes from props is sufficient for appearance adaptation. Co-Authored-By: Claude Opus 4.6 --- .../TextInput/RCTTextInputComponentView.mm | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index 34bee5db3ab0..5f2cf505076a 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -9,7 +9,6 @@ #import #import -#import // [macOS] #import #import @@ -177,20 +176,6 @@ - (void)_updateDefaultTextAttributes NSMutableDictionary *attrs = RCTNSTextAttributesFromTextAttributes(props.getEffectiveTextAttributes(RCTFontSizeMultiplier())); -#if TARGET_OS_OSX - // The C++ color pipeline resolves dynamic colors (like labelColor) to static - // values at creation time, so re-calling RCTNSTextAttributesFromTextAttributes - // after an appearance change returns the same stale color. When the foreground - // color is the default (semantic labelColor, not a user-specified color), - // replace it with a fresh dynamic NSColor.labelColor so the text adapts to the - // current appearance. Explicit colors (e.g. "white", "red") are left as-is. - const auto &effectiveAttrs = props.getEffectiveTextAttributes(RCTFontSizeMultiplier()); - facebook::react::SharedColor defaultColor = facebook::react::defaultForegroundTextColor(); - if (!effectiveAttrs.foregroundColor || *effectiveAttrs.foregroundColor == *defaultColor) { - attrs[NSForegroundColorAttributeName] = [NSColor labelColor]; - } -#endif - _backedTextInputView.defaultTextAttributes = attrs; // Also update the existing attributed text so the visible text re-renders From 409e60c64f549a2a36a707268bb75efccd6c601e Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 10 Apr 2026 16:24:05 -0700 Subject: [PATCH 3/5] fix(macOS): preserve dynamic color nature in text attribute opacity handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, calling [NSColor colorWithAlphaComponent:] on dynamic system colors (like NSColor.labelColor) converts them to static resolved colors, preventing them from adapting to appearance changes (light/dark mode). Since getEffectiveTextAttributes() always sets opacity=1 for TextInput, the colorWithAlphaComponent: call was always triggered but was effectively a no-op (multiplying alpha by 1.0). Skip it when opacity is exactly 1.0 to preserve the dynamic nature of system colors. This is the root cause fix for TextInput text not adapting to appearance changes — the previous viewDidChangeEffectiveAppearance handler was re-applying the same pre-resolved static color on each appearance change. Co-Authored-By: Claude Opus 4.6 --- .../textlayoutmanager/RCTAttributedTextUtils.mm | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 4c4097e7f260..69cb3f82848e 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -146,7 +146,13 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex { RCTPlatformColor *effectiveForegroundColor = RCTUIColorFromSharedColor(textAttributes.foregroundColor) ?: [RCTPlatformColor labelColor]; // [macOS] - if (!isnan(textAttributes.opacity)) { + // [macOS + // Skip colorWithAlphaComponent: when opacity is 1.0 — the multiplication is + // a no-op, but on macOS it has the side effect of converting dynamic system + // colors (like NSColor.labelColor) into static resolved colors, preventing + // them from adapting to appearance changes (light/dark mode). + if (!isnan(textAttributes.opacity) && textAttributes.opacity != 1.0f) { + // macOS] effectiveForegroundColor = [effectiveForegroundColor colorWithAlphaComponent:CGColorGetAlpha(effectiveForegroundColor.CGColor) * textAttributes.opacity]; } @@ -158,7 +164,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex { RCTPlatformColor *effectiveBackgroundColor = RCTUIColorFromSharedColor(textAttributes.backgroundColor); // [macOS] - if (effectiveBackgroundColor && !isnan(textAttributes.opacity)) { + if (effectiveBackgroundColor && !isnan(textAttributes.opacity) && textAttributes.opacity != 1.0f) { // [macOS] effectiveBackgroundColor = [effectiveBackgroundColor colorWithAlphaComponent:CGColorGetAlpha(effectiveBackgroundColor.CGColor) * textAttributes.opacity]; } From 92d59b490ffe1f6fc2f0724dfea9c61b019098b3 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 10 Apr 2026 16:46:15 -0700 Subject: [PATCH 4/5] fix(macOS): restructure diffs to be additive for merge friendliness Restructure the opacity != 1.0f guard in RCTAttributedTextUtils.mm to use #if TARGET_OS_OSX / #else / #endif so the upstream if-condition is preserved verbatim in the #else branch. Restore inline code in traitCollectionDidChange: to match upstream, keeping macOS additions purely additive. Co-Authored-By: Claude Opus 4.6 --- .../TextInput/RCTTextInputComponentView.mm | 12 ++++++++---- .../RCTAttributedTextUtils.mm | 19 ++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index 5f2cf505076a..cc042d8a5d60 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -202,12 +202,16 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection if (facebook::react::ReactNativeFeatureFlags::enableFontScaleChangesUpdatingLayout() && UITraitCollection.currentTraitCollection.preferredContentSizeCategory != previousTraitCollection.preferredContentSizeCategory) { - [self _updateDefaultTextAttributes]; + const auto &newTextInputProps = static_cast(*_props); + _backedTextInputView.defaultTextAttributes = + RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier())); } - if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { // [macOS] - [self _updateDefaultTextAttributes]; // [macOS] - } // [macOS] + // [macOS + if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { + [self _updateDefaultTextAttributes]; + } + // macOS] } #else // [macOS - (void)viewDidChangeEffectiveAppearance diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 69cb3f82848e..01624a8beccd 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -146,13 +146,14 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex { RCTPlatformColor *effectiveForegroundColor = RCTUIColorFromSharedColor(textAttributes.foregroundColor) ?: [RCTPlatformColor labelColor]; // [macOS] - // [macOS - // Skip colorWithAlphaComponent: when opacity is 1.0 — the multiplication is - // a no-op, but on macOS it has the side effect of converting dynamic system - // colors (like NSColor.labelColor) into static resolved colors, preventing - // them from adapting to appearance changes (light/dark mode). +#if TARGET_OS_OSX // [macOS + // On macOS, colorWithAlphaComponent: converts dynamic system colors (like + // NSColor.labelColor) to static resolved colors, preventing them from + // adapting to appearance changes. Skip when opacity is 1.0 (a no-op). if (!isnan(textAttributes.opacity) && textAttributes.opacity != 1.0f) { - // macOS] +#else // macOS] + if (!isnan(textAttributes.opacity)) { +#endif effectiveForegroundColor = [effectiveForegroundColor colorWithAlphaComponent:CGColorGetAlpha(effectiveForegroundColor.CGColor) * textAttributes.opacity]; } @@ -164,7 +165,11 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex { RCTPlatformColor *effectiveBackgroundColor = RCTUIColorFromSharedColor(textAttributes.backgroundColor); // [macOS] - if (effectiveBackgroundColor && !isnan(textAttributes.opacity) && textAttributes.opacity != 1.0f) { // [macOS] +#if TARGET_OS_OSX // [macOS + if (effectiveBackgroundColor && !isnan(textAttributes.opacity) && textAttributes.opacity != 1.0f) { +#else // macOS] + if (effectiveBackgroundColor && !isnan(textAttributes.opacity)) { +#endif effectiveBackgroundColor = [effectiveBackgroundColor colorWithAlphaComponent:CGColorGetAlpha(effectiveBackgroundColor.CGColor) * textAttributes.opacity]; } From cc6507db38d183984aa917943b2375d1a0190add Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 10 Apr 2026 18:43:10 -0700 Subject: [PATCH 5/5] fix(macOS): remove dead else branch for foreground color fallback getEffectiveTextAttributes() always sets foregroundColor (from defaultTextAttributes()) and opacity = 1, so the condition `textAttributes.foregroundColor || !isnan(textAttributes.opacity)` is always true. The #if TARGET_OS_OSX else branch could never execute. Co-Authored-By: Claude Opus 4.6 --- .../textlayoutmanager/RCTAttributedTextUtils.mm | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 01624a8beccd..9e27e70009c3 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -194,16 +194,6 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex if (textAttributes.foregroundColor || !isnan(textAttributes.opacity)) { attributes[NSForegroundColorAttributeName] = effectiveForegroundColor; } -#if TARGET_OS_OSX // [macOS - // On macOS, NSAttributedString defaults to black when no foreground color - // attribute is present, unlike iOS where the text view provides its own - // default. Always set the foreground color so that text is visible in both - // light and dark mode. RCTEffectiveForegroundColorFromTextAttributes falls - // back to the platform's dynamic labelColor when no color is specified. - else { - attributes[NSForegroundColorAttributeName] = effectiveForegroundColor; - } -#endif // macOS] if (textAttributes.backgroundColor || !isnan(textAttributes.opacity)) { attributes[NSBackgroundColorAttributeName] = RCTEffectiveBackgroundColorFromTextAttributes(textAttributes);