[iOS][Fabric] Dynamic borderColor/outlineColor (PlatformColor/DynamicColorIOS) resolve against the system appearance, ignoring overrideUserInterfaceStyle
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 72/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 静か
- 技術スタック
- ios, objective-c, react-native
- 領域
- mobile-dev
調査の方向性
packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm から始め、invalidateLayer と border-image および outline の変換パスを調べます。iOS で、システムの外観をライトにし、ダークを強制的にオーバーライドした状態で RNTester Playground の再現手順を実行します。マウント済み、新たにマウントされた、リサイクルされた View で、動的なボーダーと outline の色が View の有効な trait collection に従い、背景色が正しいままであれば完了です。
索引モデルが issue の本文から書いたものです。
説明
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.
- 主要言語
- C++
- スター
- 127k
- フォーク
- 25.3k
- 平均マージ
- 1日 23時間
- マージ済み PR(30日)
- 4
コントリビューションガイド
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
react/react-native のほかの issue
-
Needs: Triage :mag:
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
react/react-native#58565 · コメント 1 件 · リアクション 2 件 ·
-
Needs: Author Feedback Needs: Repro
難易度 2/5 1〜3時間 初心者へのやさしさ 88/100
react/react-native#58555 · コメント 4 件 · リアクション 1 件 ·
-
Needs: Attention Needs: Repro
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
react/react-native#58526 · コメント 2 件 ·
-
Needs: Author Feedback Needs: Repro
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
react/react-native#58448 · コメント 1 件 ·
-
Needs: Attention Needs: Repro
難易度 2/5 半日 初心者へのやさしさ 82/100
react/react-native#58441 · コメント 2 件 ·
react/react-native の issue をすべて見る
似ている issue
-
Website Doc Typo オープン
難易度 1/5 1時間未満 初心者へのやさしさ 92/100
-
難易度 1/5 1〜3時間 初心者へのやさしさ 92/100
autowarefoundation/autoware_universe#13413 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 88/100
-
automated-analysis bug memory-safety
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
-
難易度 2/5 1〜3時間 初心者へのやさしさ 88/100