Refactor mobile to fix dependency resolution and add polyfills

Update Expo and React dependencies to compatible versions. Create a
custom Metro config to resolve module conflicts in the workspace and
map Jest modules to local node_modules.

Add a SharedArrayBuffer polyfill for older runtimes and introduce an
adapter for expo-notifications to abstract direct imports.
This commit is contained in:
2026-05-12 22:29:38 +02:00
parent 3c6df95a86
commit 316e5def72
13 changed files with 371 additions and 150 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import * as Notifications from './src/services/expoNotifications';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
+1
View File
@@ -1,3 +1,4 @@
import './src/polyfills/sharedArrayBuffer';
import { registerRootComponent } from 'expo';
import App from './App';
+8
View File
@@ -1,4 +1,12 @@
module.exports = {
preset: 'jest-expo',
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
moduleNameMapper: {
'^react$': '<rootDir>/node_modules/react',
'^react-test-renderer$': '<rootDir>/node_modules/react-test-renderer',
'^react-native-safe-area-context$': '<rootDir>/node_modules/react-native-safe-area-context',
'^react-native-screens$': '<rootDir>/node_modules/react-native-screens',
'^@react-native-async-storage/async-storage$':
'<rootDir>/node_modules/@react-native-async-storage/async-storage',
},
};
+27
View File
@@ -0,0 +1,27 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getDefaultConfig } from 'expo/metro-config.js';
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
config.resolver.disableHierarchicalLookup = true;
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
config.resolver.extraNodeModules = {
react: path.resolve(projectRoot, 'node_modules/react'),
'react-test-renderer': path.resolve(projectRoot, 'node_modules/react-test-renderer'),
'react-native-safe-area-context': path.resolve(projectRoot, 'node_modules/react-native-safe-area-context'),
'react-native-screens': path.resolve(projectRoot, 'node_modules/react-native-screens'),
'@react-native-async-storage/async-storage': path.resolve(
projectRoot,
'node_modules/@react-native-async-storage/async-storage',
),
'expo-application': path.resolve(projectRoot, 'node_modules/expo-notifications/node_modules/expo-application'),
};
export default config;
+8 -8
View File
@@ -13,20 +13,20 @@
"test": "jest"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^3.0.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.2.4",
"@react-navigation/native-stack": "^7.14.14",
"@timetoleave/api-client": "*",
"@timetoleave/core": "*",
"expo": "~54.0.33",
"expo-calendar": "^55.0.14",
"expo-location": "^55.1.9",
"expo-notifications": "^55.0.22",
"expo-calendar": "~15.0.8",
"expo-location": "~19.0.8",
"expo-notifications": "~0.32.17",
"expo-status-bar": "~3.0.9",
"react": "19.2.4",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.24.0"
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -36,7 +36,7 @@
"eslint-plugin-react": "^7.37.5",
"jest": "^29.7.0",
"jest-expo": "~54.0.0",
"react-test-renderer": "19.2.4",
"react-test-renderer": "19.1.0",
"ts-jest": "^29.4.9",
"typescript": "~5.9.2",
"typescript-eslint": "^8.59.3"
+3 -3
View File
@@ -12,7 +12,7 @@ import {
rescheduleAllNotifications
} from '../store/eventStore';
import { calculateLeaveByTime } from '../services/notifications';
import * as Notifications from 'expo-notifications';
import * as Notifications from '../services/expoNotifications';
// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () => ({
@@ -21,8 +21,8 @@ jest.mock('@react-native-async-storage/async-storage', () => ({
removeItem: jest.fn(),
}));
// Mock expo-notifications
jest.mock('expo-notifications', () => ({
// Mock notification adapter
jest.mock('../services/expoNotifications', () => ({
getAllScheduledNotificationsAsync: jest.fn(),
cancelScheduledNotificationAsync: jest.fn(),
cancelAllScheduledNotificationsAsync: jest.fn(),
@@ -1,6 +1,6 @@
// Tests for notification service
// Mock expo-notifications before importing
jest.mock('expo-notifications', () => ({
// Mock notification adapter before importing
jest.mock('../services/expoNotifications', () => ({
setNotificationHandler: jest.fn(),
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
scheduleNotificationAsync: jest.fn().mockResolvedValue({ identifier: 'mock-id' }),
@@ -0,0 +1,78 @@
const globalScope = globalThis as Record<string, unknown>;
const stringPrototype = String.prototype as typeof String.prototype & {
isWellFormed?: () => boolean;
toWellFormed?: () => string;
};
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get;
function toWellFormedString(value: string): string {
let result = '';
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
result += value[index] + value[index + 1];
index += 1;
} else {
result += '\uFFFD';
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
result += '\uFFFD';
} else {
result += value[index];
}
}
return result;
}
if (typeof stringPrototype.toWellFormed !== 'function') {
Object.defineProperty(String.prototype, 'toWellFormed', {
configurable: true,
value() {
return toWellFormedString(String(this));
},
});
}
if (typeof stringPrototype.isWellFormed !== 'function') {
Object.defineProperty(String.prototype, 'isWellFormed', {
configurable: true,
value() {
const value = String(this);
return toWellFormedString(value) === value;
},
});
}
if (!Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'resizable')) {
Object.defineProperty(ArrayBuffer.prototype, 'resizable', {
configurable: true,
get() {
return false;
},
});
}
if (typeof globalScope.SharedArrayBuffer === 'undefined') {
class SharedArrayBufferPolyfill extends ArrayBuffer {
get byteLength() {
return arrayBufferByteLength?.call(this) ?? 0;
}
get growable() {
return false;
}
}
Object.defineProperty(SharedArrayBufferPolyfill.prototype, Symbol.toStringTag, {
configurable: true,
value: 'SharedArrayBuffer',
});
globalScope.SharedArrayBuffer = SharedArrayBufferPolyfill;
}
@@ -0,0 +1,12 @@
export { requestPermissionsAsync } from 'expo-notifications/build/NotificationPermissions.js';
export { setNotificationHandler } from 'expo-notifications/build/NotificationsHandler.js';
export { default as getAllScheduledNotificationsAsync } from 'expo-notifications/build/getAllScheduledNotificationsAsync.js';
export { default as cancelScheduledNotificationAsync } from 'expo-notifications/build/cancelScheduledNotificationAsync.js';
export { default as cancelAllScheduledNotificationsAsync } from 'expo-notifications/build/cancelAllScheduledNotificationsAsync.js';
export { default as scheduleNotificationAsync } from 'expo-notifications/build/scheduleNotificationAsync.js';
export { SchedulableTriggerInputTypes } from 'expo-notifications/build/Notifications.types.js';
export type {
NotificationBehavior,
NotificationRequest,
NotificationRequestInput,
} from 'expo-notifications/build/Notifications.types.js';
+2 -2
View File
@@ -1,5 +1,5 @@
import * as Notifications from 'expo-notifications';
import { SchedulableTriggerInputTypes } from 'expo-notifications';
import * as Notifications from './expoNotifications';
import { SchedulableTriggerInputTypes } from './expoNotifications';
import type { Event, Journey, ReminderSettings } from '@timetoleave/core';
// Register for push notification permissions
+2 -2
View File
@@ -1,7 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
import * as Notifications from 'expo-notifications';
import { SchedulableTriggerInputTypes } from 'expo-notifications';
import * as Notifications from '../services/expoNotifications';
import { SchedulableTriggerInputTypes } from '../services/expoNotifications';
// ── Keys ───────────────────────────────