Skip to content

fix(storybook-rn): load custom fonts at runtime for Expo Go compatibility#1186

Merged
georgewrmarshall merged 3 commits into
mainfrom
fix/storybook-rn-font-loading-expo-go
May 26, 2026
Merged

fix(storybook-rn): load custom fonts at runtime for Expo Go compatibility#1186
georgewrmarshall merged 3 commits into
mainfrom
fix/storybook-rn-font-loading-expo-go

Conversation

@georgewrmarshall

@georgewrmarshall georgewrmarshall commented May 22, 2026

Copy link
Copy Markdown
Contributor

Description

Custom fonts (Geist, MM Sans, MM Poly) were not rendering in the React Native Storybook app — all text fell back to the system font regardless of the fontFamily or fontWeight prop applied via the Text component.

Background

Prior to #1165, the Storybook app used native builds (expo run:ios / expo run:android), which compiled and installed a custom native binary. In that workflow, the expo-font config plugin in app.json worked correctly — it embedded font files into the native binary at build time by modifying Info.plist (UIAppFonts) and the Xcode/Gradle project.

#1165 aligned Expo and React Native dependencies with MetaMask Mobile and switched the Storybook app scripts back to Expo Go (expo start --ios / expo start --android). This is what surfaced the font regression.

Root cause

The expo-font config plugin approach only works when running a locally-built native app. Since the project now runs via Expo Go — a pre-built binary distributed by Expo — project-level native plugins are silently ignored. When React Native tried to use fontFamily: 'Geist-Medium', iOS/Android found no registered font with that PostScript name and silently fell back to the system font.

Fix

Switch to runtime font loading via the useFonts hook from expo-font, which loads fonts as Metro assets at app startup. This works in Expo Go because it uses JavaScript-layer APIs rather than native plugin execution.

Font loading is placed in the root entry component (index.tsx) rather than a Storybook decorator so fonts are guaranteed to be available before any story renders — loading in a per-story decorator can cause timing issues on Android.

Changes:

  • Renamed .rnstorybook/index.tsindex.tsx and added an App wrapper component that loads all fonts via useFonts before rendering StorybookUIRoot
  • Updated package.json main field accordingly
  • Removed the now-redundant expo-font config plugin from app.json

Related issues

Fixes:

Manual testing steps

  1. Run yarn storybook:ios and open the Text → Font Weight story — confirm Regular, Medium, and Bold render visibly different weights
  2. Open the Text → Font Family story — confirm Default (Geist), Accent (MM Sans), and Hero (MM Poly) each render in their respective typeface
  3. Run yarn storybook:android and repeat steps 1–2

Screenshots/Recordings

Before

All three font weights and all three font families rendered identically in the system font (San Francisco on iOS, Roboto on Android).

before720.mov

After

All three font weights and all three font families rendered as expected

after720.mov

Pre-merge author checklist

  • I've followed MetaMask Contributor Docs
  • I've completed the PR template to the best of my ability
  • I've included tests if applicable
  • I've documented my code using JSDoc format if applicable
  • I've applied the right labels on the PR (see labeling guidelines). Not required for external contributors.

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

Note

Low Risk
Storybook-only startup and font loading changes with no production app, auth, or data impact.

Overview
The React Native Storybook entry point now loads Geist, MM Sans, and MM Poly at runtime with useFonts instead of relying on the expo-font config plugin, so custom typefaces work when the app runs in Expo Go (native plugins are not applied there).

The root registers an App wrapper that waits for fonts before rendering StorybookUIRoot, the entry file is renamed to index.tsx with package.json main updated, and the redundant expo-font plugin block is removed from app.json.

Reviewed by Cursor Bugbot for commit 63cc501. Bugbot is set up for automated code reviews on this repo. Configure here.

…lity

Switch from expo-font config plugin (build-time) to useFonts hook
(runtime) so Geist, MM Sans, and MM Poly render correctly when running
via Expo Go without a local native build.
@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@georgewrmarshall georgewrmarshall self-assigned this May 22, 2026
@georgewrmarshall georgewrmarshall marked this pull request as ready for review May 22, 2026 19:38
@georgewrmarshall georgewrmarshall requested a review from a team as a code owner May 22, 2026 19:38
Fonts are now loaded at runtime via useFonts in index.tsx.
The config plugin only applies to native builds and is a no-op
in the current Expo Go workflow.
@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: expo-font plugin removed despite stated intent to keep
    • Restored the expo-font config plugin in apps/storybook-react-native/app.json with all existing font files to ensure native builds embed fonts.

Create PR

Or push these changes by commenting:

@cursor push 45d8f4430a
Preview (45d8f4430a)
diff --git a/apps/storybook-react-native/app.json b/apps/storybook-react-native/app.json
--- a/apps/storybook-react-native/app.json
+++ b/apps/storybook-react-native/app.json
@@ -16,6 +16,24 @@
         "backgroundColor": "#ffffff"
       }
     },
-    "plugins": []
+    "plugins": [
+      [
+        "expo-font",
+        {
+          "fonts": [
+            "./fonts/Geist/Geist-Regular.otf",
+            "./fonts/Geist/Geist-RegularItalic.otf",
+            "./fonts/Geist/Geist-Medium.otf",
+            "./fonts/Geist/Geist-MediumItalic.otf",
+            "./fonts/Geist/Geist-SemiBold.otf",
+            "./fonts/Geist/Geist-SemiBoldItalic.otf",
+            "./fonts/MMPoly/MMPoly-Regular.otf",
+            "./fonts/MMSans/MMSans-Regular.otf",
+            "./fonts/MMSans/MMSans-Medium.otf",
+            "./fonts/MMSans/MMSans-Bold.otf"
+          ]
+        }
+      ]
+    ]
   }
 }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit e0e7f70. Configure here.

Comment thread apps/storybook-react-native/app.json
});

function App() {
const [fontsLoaded] = useFonts({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Font loading lives here at the app root rather than in the Storybook preview.tsx decorator by design. A decorator re-runs per story, which can cause timing issues on Android where font registration isn't guaranteed to be complete before the first render. Loading once at startup — before StorybookUIRoot mounts — ensures fonts are always available. See useFonts docs.


function App() {
const [fontsLoaded] = useFonts({
'Geist-Regular': require('../fonts/Geist/Geist-Regular.otf'),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The keys in this map are the PostScript names of the font files, which is how iOS and Android register and look up custom fonts at runtime. These must match exactly — 'Geist-SemiBold' is the PostScript name of Geist-SemiBold.otf, not a display name. The @metamask/design-system-twrnc-preset typography config maps Tailwind font classes (e.g. font-default-bold) to these same PostScript names, which is how Text component font weights resolve correctly. See expo-font loadAsync.

});

if (!fontsLoaded) {
return null;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Returning null while fonts are loading prevents stories from rendering with the system fallback font (San Francisco on iOS, Roboto on Android) before custom fonts are ready. The blank screen is brief since font files are served locally via Metro. For a production app, expo-splash-screen would be the recommended approach to hold the splash screen during this window, but that's unnecessary overhead for a dev-only Storybook app. See Expo font loading guidance.

}
]
]
"plugins": []

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The expo-font config plugin runs during expo prebuild and modifies native project files — it adds font filenames to UIAppFonts in Info.plist on iOS and copies them into the Android assets directory. It is a build-time operation that only takes effect in a locally-compiled native app. Since the project runs via Expo Go, a pre-built binary distributed by Expo, this plugin was silently ignored and fonts were never registered with the OS. Fonts are now loaded at runtime via useFonts in index.tsx instead. See Expo config plugins.

@georgewrmarshall georgewrmarshall enabled auto-merge (squash) May 22, 2026 19:51
@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@georgewrmarshall georgewrmarshall merged commit db3ce47 into main May 26, 2026
44 checks passed
@georgewrmarshall georgewrmarshall deleted the fix/storybook-rn-font-loading-expo-go branch May 26, 2026 16:45
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.

2 participants