fix: draw the app text in the chosen reading font in release builds

This commit is contained in:
Linus Rath
2026-10-31 20:23:02 +01:00
parent db1fac4085
commit 421fa6592a
3 changed files with 82 additions and 29 deletions
+44 -11
View File
@@ -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<string, unknown>) => { 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<string, unknown>) => 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';");
});
});
+2 -5
View File
@@ -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) => {
+36 -13
View File
@@ -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<string, unknown>;
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<symbol, unknown>)[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;
}