[iOS][Fabric] Dynamic borderColor/outlineColor (PlatformColor/DynamicColorIOS) resolve against the system appearance, ignoring overrideUserInterfaceStyle
Nadie ha tomado este issue todavía.
- Lenguaje dominante
- C++
- Estrellas
- 127k
- Forks
- 25.3k
- Merge medio
- 1 d 23 h
- PR fusionados (30 d)
- 4
Descripción
Reproducer
Single-file RNTester reproducer — edits only RNTesterPlayground.js:
- Branch: https://github.com/JacquesLeupin/react-native/tree/repro/fabric-border-trait-collection-57836
- Diff vs
main: https://github.com/facebook/react-native/compare/main...JacquesLeupin:react-native:repro/fabric-border-trait-collection-57836
Run RNTester on iOS with the simulator's System Appearance set to Light and open the Playground example: it forces the app dark via Appearance.setColorScheme('dark') at 2s and mounts a second row of boxes at 4s (a button resets to the system scheme and re-runs). The same reproducer as a standalone stock-CLI app is inline under Steps to reproduce below.
Description
On the New Architecture, borderColor (all edges) and outlineColor set from a dynamic color (PlatformColor or DynamicColorIOS) resolve against the system appearance instead of the view's effective appearance. When an app forces an appearance that differs from the system — Appearance.setColorScheme('dark') (which sets overrideUserInterfaceStyle on every window) or a native overrideUserInterfaceStyle assignment — borders render the wrong appearance variant while backgroundColor and text render correctly.
Worse, the wrong border persists. Two shapes (both observed in the reproducer / a production app):
- Views already mounted when the override flips keep their stale borders:
traitCollectionDidChange:→invalidateLayerexists for exactly this, but the border conversion inside it still reads the ambient trait state rather than the view's own traits, and the borders demonstrably stay on the pre-override variant whilebackgroundColor(a UIView-managed dynamic color) corrects itself. - Recycled component views reattach under unchanged traits, so no trait-change callback ever fires for them; whatever
invalidateLayerresolved during a mounting pass (ambient = system appearance) sticks. In a production app with list recycling this makes bordered surfaces render system-variant borders indefinitely under a launch-time override.
The old architecture renders this correctly: Paper resolves border colors against the view's trait collection in RCTView (displayLayer: via borderColorsWithTraitCollection:), so this is a Fabric regression relative to Paper.
Root cause
In React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm, invalidateLayer resolves backgroundColor explicitly against the view's trait collection:
UIColor *backgroundColor = [_backgroundColor resolvedColorWithTraitCollection:self.traitCollection];
…but the border and outline paths convert dynamic UIColors straight to CGColor with no explicit resolve:
UIColor *borderColor = RCTUIColorFromSharedColor(borderMetrics.borderColors.left);
layer.borderColor = borderColor.CGColor; // resolves via UITraitCollection.currentTraitCollection
-[UIColor CGColor] on a dynamic color resolves against UITraitCollection.currentTraitCollection, which tracks the system appearance — Fabric mounting runs outside UIKit's trait-context callbacks, so it never matches a window-level overrideUserInterfaceStyle. The same flattening affects RCTCreateRCTBorderColorsFromBorderColors (the border-image path) and both outlineColor paths. (layer.shadowColor in updateProps has the same latent issue.)
Steps to reproduce
- Set the iOS Simulator/device System Appearance to Light.
- Create a stock app (
npx @react-native-community/cli init— New Architecture default) and use theApp.tsxbelow. - Launch. The app forces dark via
Appearance.setColorScheme('dark')after 2s, then mounts a second pair of boxes at 4s.
import React, {useEffect, useState} from 'react';
import {Appearance, DynamicColorIOS, StyleSheet, Text, useColorScheme, View} from 'react-native';
// ONE dynamic color for both fills and borders: red in light, green in dark.
const dynamicColor = DynamicColorIOS({light: '#ff3b30', dark: '#34c759'});
function Boxes({label}: {label: string}) {
return (
<View style={styles.section}>
<Text style={styles.sectionLabel}>{label}</Text>
<View style={styles.row}>
<View style={[styles.box, {backgroundColor: dynamicColor}]} />
<View style={[styles.box, styles.bordered, {borderColor: dynamicColor}]} />
<View style={[styles.box, styles.bordered, styles.clipped, {borderColor: dynamicColor}]} />
</View>
</View>
);
}
export default function App() {
const scheme = useColorScheme();
const [afterOverride, setAfterOverride] = useState(false);
useEffect(() => {
const t1 = setTimeout(() => Appearance.setColorScheme('dark'), 2000);
const t2 = setTimeout(() => setAfterOverride(true), 4000);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, []);
return (
<View style={styles.screen}>
<Text style={styles.title}>useColorScheme(): {String(scheme)}</Text>
<Boxes label="A: mounted BEFORE the dark override" />
{afterOverride && <Boxes label="B: mounted AFTER the dark override" />}
</View>
);
}
const styles = StyleSheet.create({
screen: {flex: 1, paddingTop: 90, paddingHorizontal: 24, backgroundColor: '#202124'},
title: {fontSize: 18, fontWeight: '600', color: '#fff'},
section: {marginTop: 16},
sectionLabel: {fontSize: 14, color: '#fff', marginBottom: 8},
row: {flexDirection: 'row', gap: 12},
clipped: {overflow: 'hidden'},
box: {width: 104, height: 80, borderRadius: 8},
bordered: {borderWidth: 6},
});
The three boxes per row exercise backgroundColor, the border-image path (default overflow), and the CoreAnimation layer.borderColor path (overflow: 'hidden').
Expected: once the app is dark (useColorScheme() reports dark), every fill and border is green.
Actual: every fill is green, but section A's borders — on both border code paths — remain red (the light variant) permanently after the override flips. Freshly-created views in section B can render correctly via the attach-time trait change; recycled views (the common case in real list-heavy apps) reattach with unchanged traits and stay wrong, which is how this presents across whole production screens.
Version and platforms
- Reproduced on 0.81.5 and 0.86.2 (New Architecture); the affected code is unchanged on
main(f7a8360696). - iOS only. Old architecture renders correctly.
Related
- #42006 (closed) — PlatformColor/DynamicColorIOS regressions on the New Architecture; the "only the initial scheme applies" half of that report matches this mechanism for borders.
- #30377 (closed, 2020) — the Paper-era version of dynamic border colors not updating, fixed for Paper via trait-collection resolution in
RCTView.
A fix PR that mirrors the existing backgroundColor handling (resolve against self.traitCollection at all four border/outline conversion sites) accompanies this issue.
Guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Línea de trabajo
Comienza en packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm e inspecciona invalidateLayer y las rutas de conversión de border-image y outline. Ejecuta el reproductor de RNTester Playground en iOS con una apariencia clara del sistema y una sobrescritura oscura forzada. Se considera terminado cuando los colores dinámicos del borde y de outline siguen la trait collection efectiva de la vista en vistas montadas, recién montadas y recicladas, mientras los colores de fondo siguen siendo correctos.
Escrito por el modelo de indexación a partir del texto del issue.
Evaluación
- Stack tecnológico
- ios, objective-c, react-native
- Área
- mobile-dev
- Tipo de issue
- Error
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Estado de actividad
- Tranquilo
- Claridad
- Bien especificado
- Aptitud para principiantes
- 72/100