From 49e5be3a9e12694618953121613b59000dc764cd Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Thu, 13 Aug 2026 21:16:21 +0700 Subject: [PATCH 1/2] fix(provider): deep merge theme prop so a partial theme keeps defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PaperProvider` spread `props.theme` over the base theme and only re-merged `colors`. Every other default sub-object — `fonts`, `shapes`, `motion`, `elevation` — was replaced wholesale, so supplying a partial `theme.fonts` wiped all 15 MD3 typescale variants and made every `` throw: Variant titleLarge was not provided properly. Valid variants are regular, medium, light, thin. Merge with `safeMerge` instead — the helper `useInternalTheme` already uses for the per-component `theme` prop — so provider-level and component-level themes merge with the same semantics. `animation.scale` is still resolved last so reduce-motion keeps overriding it. Fixes #4589 --- src/core/PaperProvider.tsx | 13 ++-- src/core/__tests__/PaperProvider.test.tsx | 87 +++++++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/core/PaperProvider.tsx b/src/core/PaperProvider.tsx index 5149efcd6f..05d7c592b2 100644 --- a/src/core/PaperProvider.tsx +++ b/src/core/PaperProvider.tsx @@ -4,7 +4,7 @@ import { getDefaultDirection, LocaleProvider, type Direction } from './locale'; import SafeAreaProviderCompat from './SafeAreaProviderCompat'; import { Provider as SettingsProvider } from './settings'; import type { Settings } from './settings'; -import { defaultThemes, ThemeProvider } from './theming'; +import { defaultThemes, safeMerge, ThemeProvider } from './theming'; import { useResolvedReduceMotion, type ReduceMotionPreference, @@ -36,11 +36,14 @@ const PaperProvider = (props: Props) => { ? 0 : (props.theme?.animation?.scale ?? 1); + // Deep merge so a partial theme extends the defaults instead of replacing + // them. `safeMerge` is the same helper `useInternalTheme` uses for the + // per-component `theme` prop, which keeps both levels consistent. + const merged = safeMerge(base, props.theme); + return { - ...base, - ...props.theme, - colors: { ...base.colors, ...props.theme?.colors }, - animation: { ...props.theme?.animation, scale }, + ...merged, + animation: { ...merged.animation, scale }, }; }, [colorScheme, props.theme, resolvedReduceMotion]); diff --git a/src/core/__tests__/PaperProvider.test.tsx b/src/core/__tests__/PaperProvider.test.tsx index 1c5d821ebe..3141de19fc 100644 --- a/src/core/__tests__/PaperProvider.test.tsx +++ b/src/core/__tests__/PaperProvider.test.tsx @@ -10,6 +10,7 @@ import { } from '@jest/globals'; import { act, render, screen } from '@testing-library/react-native'; +import Text from '../../components/Typography/Text'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { DarkTheme, DynamicLightTheme, LightTheme } from '../../theme/schemes'; import type { ThemeProp } from '../../types'; @@ -329,4 +330,90 @@ describe('PaperProvider', () => { customTheme ); }); + + describe('partial theme merging', () => { + // A v2-shaped `fonts` object, as produced by `configureFonts` before v5.13 + // and still widely copy-pasted. It shares no keys with the MD3 typescale. + const legacyFonts = { + regular: { fontFamily: 'CustomSans-Regular', fontWeight: '400' }, + medium: { fontFamily: 'CustomSans-Medium', fontWeight: '500' }, + light: { fontFamily: 'CustomSans-Light', fontWeight: '300' }, + thin: { fontFamily: 'CustomSans-Thin', fontWeight: '100' }, + } as const; + + it('keeps the base typescale when only part of theme.fonts is provided', async () => { + mockAppearance(); + await render(createProvider({ fonts: legacyFonts } as ThemeProp)); + + const theme = + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId('provider-child-view').props.theme; + + // The MD3 variants the user did not mention must survive... + expect(theme.fonts.titleLarge).toStrictEqual(LightTheme.fonts.titleLarge); + expect(theme.fonts.bodyMedium).toStrictEqual(LightTheme.fonts.bodyMedium); + // ...alongside the keys the user did provide. + expect(theme.fonts.regular).toStrictEqual(legacyFonts.regular); + }); + + it('renders instead of throwing when theme.fonts is partial', async () => { + mockAppearance(); + // Reproduces #4589: `` threw + // "Variant titleLarge was not provided properly. Valid variants are + // regular, medium, light, thin." because the provider dropped the typescale. + await render( + + Merged typescale + + ); + + expect(screen.getByText('Merged typescale')).toBeOnTheScreen(); + }); + + it('still merges theme.colors with the base palette', async () => { + mockAppearance(); + await render(createProvider({ colors: { primary: 'tomato' } })); + + const theme = + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId('provider-child-view').props.theme; + + expect(theme.colors.primary).toBe('tomato'); + expect(theme.colors.onSurface).toBe(LightTheme.colors.onSurface); + expect(Object.keys(theme.colors)).toStrictEqual( + Object.keys(LightTheme.colors) + ); + }); + + it('keeps sibling tokens when a nested shape token is overridden', async () => { + mockAppearance(); + await render(createProvider({ shapes: { corner: { small: 2 } } })); + + const theme = + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId('provider-child-view').props.theme; + + expect(theme.shapes.corner.small).toBe(2); + expect(theme.shapes.corner.large).toBe(LightTheme.shapes.corner.large); + }); + + it('lets a complete fonts object override every default variant', async () => { + mockAppearance(); + // Shaped like the output of `configureFonts`: every variant present, with + // the same properties as the defaults, so nothing can be inherited. + const completeFonts = Object.fromEntries( + Object.entries(LightTheme.fonts).map(([variant, style]) => [ + variant, + { ...style, fontFamily: 'Overridden' }, + ]) + ); + await render(createProvider({ fonts: completeFonts } as ThemeProp)); + + const theme = + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId('provider-child-view').props.theme; + + expect(theme.fonts).toStrictEqual(completeFonts); + }); + }); }); From 25363ae13ed50752f910e77954513dc164c199aa Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Sat, 22 Aug 2026 15:48:16 +0700 Subject: [PATCH 2/2] fix(theme): validate native color shape before merging as a leaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isPlatformColorSentinel` matched on key name alone, so any object owning a `dynamic`, `semantic` or `resource_paths` key was treated as an opaque native color and returned from `safeMerge` untouched. Extending the theme with custom properties is documented, so a theme like { dynamic: true, colors: { primary: 'tomato' } } was mistaken for a native color at the theme root and replaced the base theme wholesale — dropping fonts, shapes, motion and elevation. Harmless before this branch, where `safeMerge` never saw the theme root, but a real regression now that `PaperProvider` merges there. Validate the value against the shapes React Native actually emits in `PlatformColorValueTypes.{ios,android}.js`: exactly one of the three keys and nothing else, `semantic`/`resource_paths` holding an array of strings, `dynamic` holding a tuple with both `light` and `dark` and no key outside {light, dark, highContrastLight, highContrastDark}. Values from the real `PlatformColor()` and `DynamicColorIOS()` are still detected and still merged as leaves; that is asserted directly against the react-native APIs so the tightening cannot degenerate into never matching. --- src/core/__tests__/PaperProvider.test.tsx | 35 ++++++++ src/theme/__tests__/provider.test.ts | 99 +++++++++++++++++++++++ src/theme/provider.tsx | 64 +++++++++++++-- 3 files changed, 190 insertions(+), 8 deletions(-) diff --git a/src/core/__tests__/PaperProvider.test.tsx b/src/core/__tests__/PaperProvider.test.tsx index 3141de19fc..6b2f6098a7 100644 --- a/src/core/__tests__/PaperProvider.test.tsx +++ b/src/core/__tests__/PaperProvider.test.tsx @@ -397,6 +397,41 @@ describe('PaperProvider', () => { expect(theme.shapes.corner.large).toBe(LightTheme.shapes.corner.large); }); + it('keeps the defaults when the theme owns a custom property named `dynamic`', async () => { + mockAppearance(); + // `dynamic`, `semantic` and `resource_paths` are the keys that mark a + // native platform color. A user theme is allowed to own them as ordinary + // custom properties (docs: "Extending the theme"), and doing so must not + // make the whole theme look like a leaf value. + await render( + createProvider({ + dynamic: true, + colors: { primary: 'tomato' }, + } as ThemeProp) + ); + + const theme = + // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. + screen.getByTestId('provider-child-view').props.theme; + + expect(theme.dynamic).toBe(true); + expect(theme.colors.primary).toBe('tomato'); + expect(theme.colors.onSurface).toBe(LightTheme.colors.onSurface); + expect(theme.fonts.titleLarge).toStrictEqual(LightTheme.fonts.titleLarge); + expect(theme.shapes.corner.large).toBe(LightTheme.shapes.corner.large); + }); + + it('renders when the theme owns a custom `dynamic` property', async () => { + mockAppearance(); + await render( + + Custom dynamic property + + ); + + expect(screen.getByText('Custom dynamic property')).toBeOnTheScreen(); + }); + it('lets a complete fonts object override every default variant', async () => { mockAppearance(); // Shaped like the output of `configureFonts`: every variant present, with diff --git a/src/theme/__tests__/provider.test.ts b/src/theme/__tests__/provider.test.ts index e8635dfe90..7c1a494f7a 100644 --- a/src/theme/__tests__/provider.test.ts +++ b/src/theme/__tests__/provider.test.ts @@ -1,7 +1,14 @@ +import { DynamicColorIOS, PlatformColor } from 'react-native'; + import { describe, expect, it } from '@jest/globals'; import { isPlatformColorSentinel, safeMerge } from '../provider'; +// Android's `PlatformColor` cannot be exercised here (jest resolves the `.ios` +// platform extension), so its value is spelled out. Shape taken verbatim from +// react-native/Libraries/StyleSheet/PlatformColorValueTypes.android.js. +const androidPlatformColor = { resource_paths: ['@android:color/black'] }; + describe('isPlatformColorSentinel', () => { it('detects iOS PlatformColor (semantic)', () => { expect(isPlatformColorSentinel({ semantic: ['label'] })).toBe(true); @@ -19,6 +26,62 @@ describe('isPlatformColorSentinel', () => { ).toBe(true); }); + it('detects values produced by the real react-native APIs', () => { + // Guards against the shape validation below degenerating into + // "nothing is ever a sentinel", which would let deepmerge corrupt + // genuine platform colors again. + expect(isPlatformColorSentinel(PlatformColor('label'))).toBe(true); + expect( + isPlatformColorSentinel(DynamicColorIOS({ light: '#fff', dark: '#000' })) + ).toBe(true); + expect( + isPlatformColorSentinel( + DynamicColorIOS({ + light: '#fff', + dark: '#000', + highContrastLight: '#eee', + highContrastDark: '#111', + }) + ) + ).toBe(true); + expect(isPlatformColorSentinel(androidPlatformColor)).toBe(true); + }); + + it('rejects custom theme properties that only reuse a sentinel key name', () => { + // Extending the theme with arbitrary properties is documented, so a theme + // is allowed to own a key called `dynamic`, `semantic` or `resource_paths`. + expect(isPlatformColorSentinel({ dynamic: true })).toBe(false); + expect(isPlatformColorSentinel({ dynamic: 'auto' })).toBe(false); + expect(isPlatformColorSentinel({ semantic: 'label' })).toBe(false); + expect(isPlatformColorSentinel({ semantic: [1, 2] })).toBe(false); + expect(isPlatformColorSentinel({ resource_paths: true })).toBe(false); + }); + + it('rejects objects that carry a sentinel key alongside other keys', () => { + // A whole theme is not a platform color, even when one of its properties + // happens to be shaped like `DynamicColorIOS`'s tuple. + expect( + isPlatformColorSentinel({ + dynamic: { light: '#fff', dark: '#000' }, + colors: { primary: 'tomato' }, + }) + ).toBe(false); + expect(isPlatformColorSentinel({ semantic: ['label'], fonts: {} })).toBe( + false + ); + }); + + it('rejects `dynamic` values that are not a light/dark tuple', () => { + expect(isPlatformColorSentinel({ dynamic: {} })).toBe(false); + expect(isPlatformColorSentinel({ dynamic: { light: '#fff' } })).toBe(false); + expect(isPlatformColorSentinel({ dynamic: { dark: '#000' } })).toBe(false); + expect( + isPlatformColorSentinel({ + dynamic: { light: '#fff', dark: '#000', scale: 1 }, + }) + ).toBe(false); + }); + it('rejects plain objects, primitives, null, and arrays', () => { expect(isPlatformColorSentinel({ primary: '#fff' })).toBe(false); expect(isPlatformColorSentinel('#fff')).toBe(false); @@ -98,6 +161,42 @@ describe('safeMerge', () => { expect(result.colors.primary).toBe(sentinelOverride); }); + it('keeps the base when overrides own a custom property named `dynamic`', () => { + const base = { + fonts: { titleLarge: { fontSize: 22 } }, + colors: { primary: '#000' }, + }; + const overrides = { dynamic: true }; + + const result = safeMerge( + base, + overrides + ); + + expect(result.fonts).toStrictEqual(base.fonts); + expect(result.colors).toStrictEqual(base.colors); + expect(result.dynamic).toBe(true); + }); + + it('still treats a real DynamicColorIOS override as a leaf, not a merge target', () => { + const baseColor = DynamicColorIOS({ + light: '#000', + dark: '#111', + highContrastLight: '#222', + highContrastDark: '#333', + }); + const overrideColor = DynamicColorIOS({ light: '#fff', dark: '#eee' }); + const base = { colors: { primary: baseColor } }; + const overrides = { colors: { primary: overrideColor } }; + + const result = safeMerge<{ colors: { primary: any } }>(base, overrides); + + // Identity: the override object is passed through untouched... + expect(result.colors.primary).toBe(overrideColor); + // ...and nothing was inherited from the base sentinel underneath it. + expect(result.colors.primary.dynamic.highContrastLight).toBeUndefined(); + }); + it('preserves sentinel siblings when merging a colors map', () => { const sentinel = { semantic: ['label'] }; const base = { diff --git a/src/theme/provider.tsx b/src/theme/provider.tsx index b4101eaa0f..df693209ed 100644 --- a/src/theme/provider.tsx +++ b/src/theme/provider.tsx @@ -19,15 +19,63 @@ export function useTheme(overrides?: $DeepPartial) { return useThemeBase(overrides); } +const isStringArray = (v: unknown): boolean => + Array.isArray(v) && v.every((item) => typeof item === 'string'); + +const DYNAMIC_TUPLE_KEYS = [ + 'light', + 'dark', + 'highContrastLight', + 'highContrastDark', +]; + +// `DynamicColorIOS` always emits both `light` and `dark` (either may be +// nullish) and never any key outside the tuple above. +const isDynamicColorIOSTuple = (v: unknown): boolean => { + if (!v || typeof v !== 'object' || Array.isArray(v)) { + return false; + } + const keys = Object.keys(v); + return ( + keys.includes('light') && + keys.includes('dark') && + keys.every((key) => DYNAMIC_TUPLE_KEYS.includes(key)) + ); +}; + // Upstream `deepmerge` corrupts PlatformColor objects, so we recurse manually -// and treat sentinels as leaves. Three shapes: -// `semantic` — iOS PlatformColor -// `dynamic` — DynamicColorIOS -// `resource_paths` — Android PlatformColor -export const isPlatformColorSentinel = (v: unknown): boolean => - !!v && - typeof v === 'object' && - ('resource_paths' in v || 'semantic' in v || 'dynamic' in v); +// and treat sentinels as leaves. Three shapes, straight from React Native's +// `PlatformColorValueTypes.{ios,android}.js`: +// `{ semantic: string[] }` — iOS PlatformColor +// `{ dynamic: { light, dark, ...} }` — DynamicColorIOS +// `{ resource_paths: string[] }` — Android PlatformColor +// The shape has to be validated, not just the key name: a theme may own a +// custom property called `dynamic`, `semantic` or `resource_paths` (extending +// the theme with arbitrary properties is documented), and treating such a +// theme as a leaf would drop every default it did not spell out. +export const isPlatformColorSentinel = (v: unknown): boolean => { + if (!v || typeof v !== 'object' || Array.isArray(v)) { + return false; + } + // A native color value carries exactly one of the three keys and nothing + // else, so anything with siblings is a regular object. + const keys = Object.keys(v); + if (keys.length !== 1) { + return false; + } + const [key] = keys; + const value = (v as Record)[key]; + + switch (key) { + case 'semantic': + case 'resource_paths': + return isStringArray(value); + case 'dynamic': + return isDynamicColorIOSTuple(value); + default: + return false; + } +}; export const safeMerge = (base: T, overrides: unknown): T => { if (