Skip to content

fix(provider): deep merge theme prop so a partial theme keeps defaults - #5047

Open
giaBaoJS wants to merge 2 commits into
callstack:mainfrom
giaBaoJS:fix/paper-provider-partial-theme-merge-4589
Open

fix(provider): deep merge theme prop so a partial theme keeps defaults#5047
giaBaoJS wants to merge 2 commits into
callstack:mainfrom
giaBaoJS:fix/paper-provider-partial-theme-merge-4589

Conversation

@giaBaoJS

Copy link
Copy Markdown

Summary

PaperProvider replaces the whole theme object instead of merging it, so any partial theme prop silently drops the defaults it doesn't mention. The most visible casualty is fonts: supplying one makes every <Text variant="…"> in the app throw.

Fixes #4589.

Reproduction

@lukewalczak asked for a repro on the issue and never got one, so here it is as a runnable snippet — this is the whole thing, no app scaffolding needed:

import { PaperProvider, Text } from 'react-native-paper';

// Anything partial will do. This is the v2 `configureFonts` shape that most
// people upgrading from v4/v5 still have in their codebase.
const theme = {
  fonts: {
    regular: { fontFamily: 'System', fontWeight: '400' },
    medium: { fontFamily: 'System', fontWeight: '500' },
    light: { fontFamily: 'System', fontWeight: '300' },
    thin: { fontFamily: 'System', fontWeight: '100' },
  },
};

export default () => (
  <PaperProvider theme={theme}>
    <Text variant="titleLarge">Hello</Text>
  </PaperProvider>
);

Throws immediately:

Variant titleLarge was not provided properly.
Valid variants are regular, medium, light, thin.

That trailing list is literally Object.keys(theme.fonts) in Text.tsx#L136-L138 printing back the user's four keys — proof that the 15 MD3 typescale variants are gone by the time Text reads the theme, not that the user's fonts was malformed. The in-thread workaround (pinning to 5.12.3) matches: this predates the typescale becoming mandatory.

It is committed as a regression test in this PR, so the repro lives in CI rather than in a Snack that can rot.

Root cause

src/core/PaperProvider.tsx#L39-L44:

return {
  ...base,
  ...props.theme,
  colors: { ...base.colors, ...props.theme?.colors },   // merged
  animation: { ...props.theme?.animation, scale },
};

colors gets re-merged against base on the line below the spread. Nothing else does. ...props.theme therefore replaces fonts, shapes, motion and elevation wholesale with whatever fragment the user passed.

Worth stressing that this is an internal inconsistency, not just a missing case. src/theme/provider.tsx already exports safeMerge, and useInternalTheme uses it to deep-merge the per-component theme prop. So today:

<Button theme={{ fonts: { regular } }} />      {/* deep merged — fine   */}
<PaperProvider theme={{ fonts: { regular } }} /> {/* shallow — wipes it */}

The same prop, on the same shape, with two different merge semantics depending on where you put it.

fonts is what #4589 reports because it is the only sub-object with a hard throw behind it. shapes, motion and elevation fail identically but silently — and they're nested two or three levels deep (shapes.corner.small, motion.spring.fast.spatial.stiffness), so a one-level merge wouldn't save them either.

The fix

-    return {
-      ...base,
-      ...props.theme,
-      colors: { ...base.colors, ...props.theme?.colors },
-      animation: { ...props.theme?.animation, scale },
-    };
+    const merged = safeMerge(base, props.theme);
+
+    return {
+      ...merged,
+      animation: { ...merged.animation, scale },
+    };

Reusing safeMerge rather than adding a fonts: line next to the colors: one, because:

  • it fixes shapes / motion / elevation too, which have the identical defect and are too deeply nested for a shallow merge;
  • it makes the provider-level and component-level theme props agree, which is the actual inconsistency here;
  • it is the helper you already reach for in this exact situation, including the PlatformColor sentinel handling added in 8720eac — no new merge logic enters the codebase;
  • it is a smaller diff than enumerating five sub-objects.

animation.scale is still applied last so reduceMotion keeps overriding it.

I'm happy to switch to the minimal, fonts-only version if you'd rather keep the blast radius at exactly the reported bug:

       colors: { ...base.colors, ...props.theme?.colors },
+      fonts: { ...base.fonts, ...props.theme?.fonts },

Just say the word and I'll swap it — but it leaves the other three broken.

Behaviour change to be aware of

Anyone deliberately replacing rather than extending a sub-object now gets the defaults merged underneath it. In practice this is hard to observe: a complete configureFonts() output overrides every key anyway (covered by a test below), and there's no way to express "and delete the rest" through a $DeepPartial prop today. But it is a real semantic change and worth a line in the release notes.

Deep merge also means individual variants inherit missing properties — fonts: { titleLarge: { fontSize: 20 } } now keeps the default lineHeight/letterSpacing instead of dropping them. That's the behaviour useInternalTheme already has, and it's the friendlier of the two.

Tests

Five tests added to src/core/__tests__/PaperProvider.test.tsx, using the existing FakeChild + useTheme harness. Three fail on main, two pass on main as controls.

Fail before the fix:

  • keeps the base typescale when only part of theme.fonts is providedtheme.fonts.titleLarge is undefined on main.
  • renders <Text variant> instead of throwing when theme.fonts is partial — asserts the user-visible symptom. On main it fails with the issue's error string verbatim, which is the point: the shape assertion above could pass while the app still crashed.
  • keeps sibling tokens when a nested shape token is overridden — demonstrates the non-fonts half of the bug.

Pass before and after, as non-vacuity controls:

  • still merges theme.colors with the base palettecolors was already correct; this proves the harness observes real merging rather than always going green.
  • lets a complete fonts object override every default variant — guards the behaviour change above: a full typescale still wins outright.

I deliberately did not add a test for the animation line. ...props.theme?.animation drops base.animation for the same reason, but Theme['animation'] currently has exactly one key (scale) which is recomputed on the next line, so the omission is unobservable and any test for it would pass with or without the change. The fix restores ...merged.animation anyway so it stays correct if a second key is ever added.

Verification

Before:  55 suites, 732 passed, 1 skipped, 169 snapshots — exit 0
After:   55 suites, 737 passed, 1 skipped, 169 snapshots — exit 0

Zero snapshot churn — all 169 pass unwritten, since they render under the default theme where deep and shallow merge coincide. yarn lint and yarn typecheck are clean.

`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
`<Text variant="...">` 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 callstack#4589
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calling safeMerge at the theme root can drop all defaults when a custom theme contains dynamic, semantic or resource_paths

current sentinel check treats any object containing one of these keys as a native color, while custom theme properties are supported

safeMerge(base, { dynamic: true });
// => { dynamic: true }, dropping fonts, colors, shapes etc.

RN’s actual native-color values have narrower shapes, such as { semantic: string[] } or { dynamic: { light, dark } } (source)

so could we validate those shapes before using safeMerge here & add regression test for custom dynamic property?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right, and this is a real regression introduced by moving safeMerge to the theme root. Fixed in 25363ae.

Reproduced first: safeMerge(base, { dynamic: true }) returned { dynamic: true }, and <PaperProvider theme={{ dynamic: true }}> made <Text variant="titleLarge"> throw again — the exact symptom this PR set out to fix.

isPlatformColorSentinel now validates the value, not just the key name. Checking the shapes React Native actually emits (PlatformColorValueTypes.ios.js L25-28 and L37-50, PlatformColorValueTypes.android.js L19-23), a native color value carries exactly one key and nothing else, so the check now accepts only:

  • { semantic: string[] } — iOS PlatformColor
  • { resource_paths: string[] } — Android PlatformColor
  • { dynamic: { light, dark, highContrastLight?, highContrastDark? } }DynamicColorIOS, requiring both light and dark and rejecting any key outside that tuple

Everything else — including { dynamic: true }, { semantic: 'label' }, and any object that carries a sentinel key alongside siblings such as colors/fonts — is now an ordinary object and gets deep-merged, so a documented custom theme property called dynamic keeps every default.

Regression tests added, as requested:

  • PaperProvider.test.tsx: a theme { dynamic: true, colors: { primary: 'tomato' } } keeps the typescale, palette and shape tokens, plus a user-visible one asserting <Text variant="titleLarge"> renders under theme={{ dynamic: true }}.
  • provider.test.ts: rejection cases for the three key names with wrong-shaped values and with sibling keys.

Two of the new tests are deliberately non-vacuous controls that pass both before and after, so the tightening can't quietly degenerate into "nothing is ever a sentinel": one asserts real PlatformColor('label') and DynamicColorIOS(...) values are still detected, and one asserts a real DynamicColorIOS override still passes through safeMerge by identity, with no highContrast* keys inherited from the base sentinel underneath it. With the sentinel change reverted the six new tests fail and those two still pass.

Suite: 55 suites / 737 → 745 passed / 1 skipped, 169 snapshots unchanged; lint and typecheck clean.

`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Warning: Error: Variant titleLarge was not provided properly. Valid variants are regular, medium, light, thin.

2 participants