mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: draw the app text in the chosen reading font in release builds
This commit is contained in:
@@ -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';");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user