From 421fa6592a58865de7eed0f011a379e40e8c83ed Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:23:02 +0100 Subject: [PATCH] fix: draw the app text in the chosen reading font in release builds --- src/theme/__tests__/reading-fonts.test.ts | 55 ++++++++++++++++++----- src/theme/reading-font-install.ts | 7 +-- src/theme/reading-font-text.ts | 49 ++++++++++++++------ 3 files changed, 82 insertions(+), 29 deletions(-) diff --git a/src/theme/__tests__/reading-fonts.test.ts b/src/theme/__tests__/reading-fonts.test.ts index 289479de..8817e689 100644 --- a/src/theme/__tests__/reading-fonts.test.ts +++ b/src/theme/__tests__/reading-fonts.test.ts @@ -26,7 +26,7 @@ import { getUiFontFamily, setUiFontFamily, withFontFamily, - wrapModuleDefaults, + wrapNamedExports, wrapWithReadingFont, } from '../reading-font-text'; import { APP_FONTS, mergeWithDefaults } from '../../stores/settings-store'; @@ -191,15 +191,48 @@ describe('the reading font on the app text', () => { setUiFontFamily(null); }); - it('wraps each module default once and leaves non-functions alone', () => { - const text = { default: () => null }; - const memo = { default: { $$typeof: Symbol.for('react.memo') } }; - const original = text.default; - expect(wrapModuleDefaults([text, memo])).toBe(1); - expect(text.default).not.toBe(original); - const wrapped = text.default; - expect(wrapModuleDefaults([text])).toBe(0); - expect(text.default).toBe(wrapped); - expect(memo.default).toEqual({ $$typeof: Symbol.for('react.memo') }); + // The shapes in a release bundle (expo export:embed --dev false): the Text + // module's default export is a getter without a setter, and react-native's + // index exposes Text through a configurable lazy getter that reads it. + function releaseModules() { + const seen: unknown[] = []; + const TextImpl = (props: Record) => { seen.push(props); return null; }; + const textModule = {}; + Object.defineProperty(textModule, '__esModule', { value: true }); + Object.defineProperty(textModule, 'default', { enumerable: true, get: () => TextImpl }); + const reactNative = { + get Text() { return (textModule as { default: typeof TextImpl }).default; }, + get TextInput() { return { $$typeof: Symbol.for('react.forward_ref') }; }, + }; + return { seen, TextImpl, textModule, reactNative }; + } + + it('cannot replace the Text module default of a release bundle', () => { + const { TextImpl, textModule } = releaseModules(); + // What the app used to do: the assignment fails and the plain Text stays. + expect(() => { (textModule as { default: unknown }).default = () => null; }).toThrow(TypeError); + expect((textModule as { default: unknown }).default).toBe(TextImpl); + }); + + it('wraps Text on react-native’s exports, which app code reads at render time', () => { + const { seen, TextImpl, reactNative } = releaseModules(); + expect(wrapNamedExports(reactNative, ['Text', 'TextInput'])).toBe(1); + const Text = reactNative.Text as unknown as (props: Record) => unknown; + expect(Text).not.toBe(TextImpl); + setUiFontFamily('OpenDyslexic'); + Text({ style: { color: 'red' }, children: 'Inbox' }); + expect(seen.pop()).toEqual({ style: [{ fontFamily: 'OpenDyslexic' }, { color: 'red' }], children: 'Inbox' }); + setUiFontFamily(null); + // Once only; a forwardRef object is left alone. + expect(wrapNamedExports(reactNative, ['Text', 'TextInput'])).toBe(0); + expect(reactNative.Text).toBe(Text); + expect(reactNative.TextInput).toEqual({ $$typeof: Symbol.for('react.forward_ref') }); + }); + + it('is what the startup install wraps, before the app loads', () => { + const install = readFileSync(join(__dirname, '..', 'reading-font-install.ts'), 'utf8'); + expect(install).toMatch(/wrapNamedExports\(require\('react-native'\), \['Text', 'TextInput'\]\)/); + const entry = readFileSync(join(__dirname, '..', '..', '..', 'index.ts'), 'utf8'); + expect(entry.match(/^import .*$/m)?.[0]).toBe("import './src/theme/reading-font-install';"); }); }); diff --git a/src/theme/reading-font-install.ts b/src/theme/reading-font-install.ts index 5b0ed8cc..4b930bec 100644 --- a/src/theme/reading-font-install.ts +++ b/src/theme/reading-font-install.ts @@ -6,14 +6,11 @@ /* eslint-disable @typescript-eslint/no-require-imports */ import { Platform } from 'react-native'; -import { setUiFontFamily, wrapModuleDefaults } from './reading-font-text'; +import { setUiFontFamily, wrapNamedExports } from './reading-font-text'; import { uiFontFamily, uiFontsBundled } from './reading-fonts'; if (uiFontsBundled(Platform.OS)) { - wrapModuleDefaults([ - require('react-native/Libraries/Text/Text'), - require('react-native/Libraries/Components/TextInput/TextInput'), - ]); + wrapNamedExports(require('react-native'), ['Text', 'TextInput']); const { useSettingsStore } = require('../stores/settings-store') as typeof import('../stores/settings-store'); setUiFontFamily(uiFontFamily(useSettingsStore.getState().appFont, Platform.OS)); useSettingsStore.subscribe((state, prev) => { diff --git a/src/theme/reading-font-text.ts b/src/theme/reading-font-text.ts index adbbd1ee..7b0650d2 100644 --- a/src/theme/reading-font-text.ts +++ b/src/theme/reading-font-text.ts @@ -8,10 +8,18 @@ // chosen family under the caller's own style (an explicit fontFamily still // wins) and re-renders when the setting changes. // +// The wrapped components replace the `Text` and `TextInput` getters on +// react-native's own exports object, which every `import { Text } from +// 'react-native'` reads at render time. Replacing the `default` export of +// react-native/Libraries/Text/Text instead worked in a dev bundle only: the +// release bundle defines that export as a getter without a setter +// (`Object.defineProperty(exports, "default", { get })`), so the assignment +// failed, was swallowed, and the app text stayed in the theme font while the +// plain-text mail body (which takes the font another way) changed. +// // Wrapping happens before any screen module loads (index.ts imports -// ./reading-font-install first): with Metro's import support every module -// copies `Text` out of react-native when it is evaluated, so a module loaded -// earlier keeps the plain component. The wrapper stays installed for the +// ./reading-font-install first), so a module that copies the exports (an +// `import * as`) copies the wrapped ones. The wrapper stays installed for the // whole run, also while the theme font is chosen; swapping the component // later would remount every text field. @@ -66,23 +74,38 @@ export function wrapWithReadingFont(Original: FunctionComponent): FunctionCompon } /** - * Wraps the default export of each module (react-native's Text and - * TextInput) in place. Only plain function components are wrapped; anything - * else (a future memo or forwardRef object) is left alone. Returns how many - * were wrapped; a second call wraps nothing. + * Replaces the named components on `exports` (react-native's exports object: + * Text, TextInput) with wrapped ones. A getter is redefined as a getter that + * returns the wrapped component, as react-native's own lazy getters are + * configurable; a property that can be neither redefined nor assigned is + * left alone. Only plain function components are wrapped; anything else (a + * future memo or forwardRef object) is left alone. Returns how many were + * wrapped; a second call wraps nothing. */ -export function wrapModuleDefaults(modules: Array<{ default?: unknown }>): number { +export function wrapNamedExports(exports: object, names: readonly string[]): number { + const target = exports as Record; let wrapped = 0; - for (const mod of modules) { - const component = mod?.default; + for (const name of names) { + let component: unknown; + try { + component = target[name]; + } catch { + continue; + } if (typeof component !== 'function') continue; if ((component as unknown as Record)[WRAPPED]) continue; + const next = wrapWithReadingFont(component as FunctionComponent); + const own = Object.getOwnPropertyDescriptor(target, name); try { - mod.default = wrapWithReadingFont(component as FunctionComponent); - wrapped++; + if (!own || own.configurable) { + Object.defineProperty(target, name, { configurable: true, enumerable: own?.enumerable ?? true, get: () => next }); + } else if (own.writable) { + target[name] = next; + } } catch { - // A read-only export: leave the component as it is. + // Neither redefinable nor writable: leave the component as it is. } + if (target[name] === next) wrapped++; } return wrapped; }