Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# SubTrackr Development Commands

## Lint and Type Check
```bash
npm run lint # ESLint for TypeScript files
npm run typecheck # TypeScript type checking
npm run format # Format code with Prettier
npm run format:check # Check formatting
```

## Testing
```bash
npm run test # Run Jest tests
npm run test:coverage # Run tests with coverage
npm run performance:ci # Check performance budget
```

## Build
```bash
npm run build:android # Android release build
npm run android # Run on Android
npm run android:device # Run on Android device
```

## Performance Budget Thresholds (Android)
- Render time: 250ms (p95)
- API latency: 1200ms (p95)
- Memory usage: 262MB
- Startup time: 2000ms (target: <2s)
- Frame rate: 60fps (target for mid-range devices)
57 changes: 48 additions & 9 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { View } from 'react-native';
import { View, Alert, Platform } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { AppNavigator } from './src/navigation/AppNavigator';
Expand All @@ -9,20 +9,34 @@ import ErrorBoundary from './src/components/ErrorBoundary';
import { initI18n } from './src/i18n/config';
import i18n from './src/i18n/config';
import { I18nextProvider } from 'react-i18next';
import { crashReporter, CrashRecord } from './src/services/crashReporter';
import * as Sentry from '@sentry/react-native';

import './src/config/env';

// Import WalletConnect compatibility layer
import '@walletconnect/react-native-compat';

import { initHermesOptimizations } from './src/utils/startupTimeOptimizer';

import { createAppKit, defaultConfig, AppKit } from '@reown/appkit-ethers-react-native';

import { EVM_RPC_URLS } from './src/config/evm';
import { useNetworkStore, useSettingsStore } from './src/store';
import { sessionService } from './src/services/auth/session';

// Get projectId from environment variable
const projectId = process.env.WALLET_CONNECT_PROJECT_ID || 'YOUR_PROJECT_ID';
const projectId = env.WALLET_CONNECT_PROJECT_ID;

try {
Sentry.init({
dsn: process.env.SENTRY_DSN || '',
enableAutoSessionTracking: true,
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE || 0.05),
environment: process.env.NODE_ENV || 'production',
});
} catch (e) {
console.warn('Sentry init failed', e);
}

// Create metadata
const metadata = {
name: 'SubTrackr',
description: 'Subscription Management with Crypto Payments',
Expand All @@ -35,7 +49,6 @@ const metadata = {

const config = defaultConfig({ metadata });

// Define supported chains
const mainnet = {
chainId: 1,
name: 'Ethereum',
Expand All @@ -62,7 +75,6 @@ const arbitrum = {

const chains = [mainnet, polygon, arbitrum];

// Create AppKit
createAppKit({
projectId,
metadata,
Expand All @@ -79,9 +91,22 @@ function NotificationBootstrap() {
const { initializeSettings } = useSettingsStore();

React.useEffect(() => {
if (Platform.OS === 'android') {
initHermesOptimizations();
}
initialize();
void initializeSettings();
void sessionService.initializeCurrentSession();
void (async () => {
const session = await sessionService.initializeCurrentSession();
try {
Sentry.setContext('session', { id: session.id, deviceName: session.deviceName });
if (wallet?.address) {
Sentry.setUser({ id: wallet.address });
}
} catch (e) {
// ignore
}
})();
}, [initialize, initializeSettings]);

return null;
Expand All @@ -95,6 +120,20 @@ export default function App() {
const run = async () => {
try {
await initI18n();

const previousCrash = await crashReporter.initialize({
preservedStorageKeys: [
'@subtrackr/settings',
'@subtrackr/auth_token',
'@subtrackr/preferred_currency',
],
installGlobalHandler: true,
});

if (previousCrash && !cancelled) {
setPendingCrash(previousCrash);
setShowRecoveryModal(true);
}
} finally {
if (!cancelled) setI18nReady(true);
}
Expand All @@ -121,4 +160,4 @@ export default function App() {
</View>
</GestureHandlerRootView>
);
}
}
10 changes: 9 additions & 1 deletion app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,20 @@ module.exports = ({ config }) => ({
android: {
...appJson.expo.android,
package: isProduction ? 'com.subtrackr.app' : `com.subtrackr.app.${env}`,
jsEngine: 'hermes',
hermesFlags: ['-g', '--minify', '--inline-store-on-put', '--allocation-profile'],
},
plugins: ['expo-dev-client', ...(appJson.expo.plugins || [])],
extra: {
...appJson.expo.extra,
appEnv: env,
apiUrl: process.env.EXPO_PUBLIC_API_URL || 'https://sandbox.api.subtrackr.app',
nativeDebuggingEnabled: !isProduction,
hermesOptimizations: {
enabled: true,
inlineStoreOnPut: true,
allocationProfile: true,
bytecodeCache: true,
},
},
});
});
6 changes: 4 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
],
"jsEngine": "hermes",
"hermesFlags": ["-g", "--minify", "--inline-store-on-put", "--allocation-profile"]
},
"web": {
"favicon": "./assets/subtrackr-icon.png",
Expand All @@ -67,4 +69,4 @@
"@config-plugins/detox"
]
}
}
}
16 changes: 15 additions & 1 deletion babel.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
module.exports = function (api) {
api.cache(true);
const isProduction = api.env('production');

const plugins = [
['babel-plugin-module-resolver', {
root: ['./src'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
}],
];

if (isProduction) {
plugins.push(['babel-plugin-transform-remove-console', { exclude: ['error', 'warn'] }]);
}

return {
presets: [['babel-preset-expo', { unstable_transformImportMeta: true }]],
plugins,
};
};
};
8 changes: 7 additions & 1 deletion eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
},
"env": {
"APP_ENV": "preview",
"EXPO_PUBLIC_API_URL": "https://sandbox.api.subtrackr.app"
}
},
"production": {
"autoIncrement": true,
"android": {
"buildType": "apk"
},
"env": {
"APP_ENV": "production",
"EXPO_PUBLIC_API_URL": "https://api.subtrackr.app"
Expand All @@ -36,4 +42,4 @@
"submit": {
"production": {}
}
}
}
70 changes: 17 additions & 53 deletions metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,61 +2,25 @@ const { getDefaultConfig } = require('expo/metro-config');

const config = getDefaultConfig(__dirname);

// ─── Tree-shaking / minification ─────────────────────────────────────────────
// Enable minification in production so unused code paths are removed by the
// Metro bundler's inline-requires and dead-code-elimination passes.
config.transformer = {
...config.transformer,
// Inline requires defers module evaluation until first use — this effectively
// implements lazy loading for heavy modules (ethers, stellar-sdk, etc.)
// and removes them from the critical path entirely when not needed.
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
};

// ─── Resolver: platform-specific module aliases ───────────────────────────────
// Prefer the ES-module (tree-shakeable) entry point for libraries that ship
// both CJS and ESM builds.
config.resolver = {
...config.resolver,
// Prioritise .mjs then .js so bundler picks up ESM where available
sourceExts: ['mjs', 'js', 'jsx', 'ts', 'tsx', 'cjs', 'json'],
};

// ─── Bundle analyser ──────────────────────────────────────────────────────────
// Run: EXPO_BUNDLE_ANALYZE=true npx expo export
// Then: npx react-native-bundle-visualizer
// Or: npx metro-viz (if installed)
//
// We wire this through an env flag so CI stays fast.
if (process.env.EXPO_BUNDLE_ANALYZE === 'true') {
// metro-bundle-analyzer serialises a stats JSON alongside the bundle
const { MetroBundleAnalyzerPlugin } = (() => {
try {
return require('metro-bundle-analyzer');
} catch {
console.warn(
'[metro] metro-bundle-analyzer not installed. ' +
'Run: npm install --save-dev metro-bundle-analyzer'
);
return { MetroBundleAnalyzerPlugin: null };
}
})();
config.transformer.hermesEnabled = true;
config.transformer.unstable_transformImportMeta = true;

if (MetroBundleAnalyzerPlugin) {
config.serializer = {
...config.serializer,
customSerializer: MetroBundleAnalyzerPlugin.createSerializer({
enabled: true,
openAnalyzer: false, // don't auto-open browser in CI
fileName: 'bundle-stats.json', // output alongside dist/
}),
};
if (process.env.NODE_ENV === 'production') {
config.transformer.minifierConfig = {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.info', 'console.debug', 'console.trace'],
},
};
try {
const hermesSerializer = require('@shopify/metro-serializer-hermes');
config.serializer.customSerializer = hermesSerializer.serializer;
} catch (e) {
// Serializer not available, continue without it
}
}

config.resolver.unstable_enablePackageExports = true;

module.exports = config;
11 changes: 5 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,11 @@
"contracts:migrate:validate": "./scripts/validate-migration.sh",
"contracts:migrate:rollback": "./scripts/rollback-migration.sh",
"contracts:verify": "cd contracts/subscription/certora && certoraRun ../src/lib.rs --verify SubTrackrSubscription:SubTrackrSubscription.spec --msg \"SubTrackr local formal verification\"",
"contracts:codegen": "typechain --target ethers-v5 --out-dir src/contracts/types \"src/contracts/abis/**/*.json\"",
"contracts:codegen:check": "npm run contracts:codegen && git diff --exit-code -- src/contracts/types src/contracts/abis",
"release": "semantic-release",
"release:dry-run": "semantic-release --dry-run",
"prebuild": "npm run contracts:codegen",
"pretypecheck": "npm run contracts:codegen",
"ci": "npm run lint && npm run contracts:codegen:check && npx tsc --noEmit && npm run test && npm run contracts:test && npm run contracts:fmt && npm run contracts:clippy",
"prebuild": "husky",
"pretypecheck": "husky",
"ci": "npm run lint && npx tsc --noEmit && npm run test && npm run contracts:test && npm run contracts:fmt && npm run contracts:clippy",
"prepare": "husky",
"load:test": "k6 run load-tests/run.js",
"load:test:subscription": "k6 run load-tests/run.js --env SCENARIO=subscription",
Expand Down Expand Up @@ -137,6 +135,7 @@
"react-test-renderer": "^19.2.5",
"semantic-release": "^24.2.9",
"size-limit": "^11.1.4",
"@shopify/metro-serializer-hermes": "^1.0.0",
"ts-jest": "^29.4.11",
"typechain": "^8.3.2",
"typescript": "~5.8.3"
Expand All @@ -162,4 +161,4 @@
"prettier --write"
]
}
}
}
12 changes: 10 additions & 2 deletions performance-budget.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
{
"renderMs": 250,
"apiLatencyMs": 1200,
"memoryBytes": 262144000
}
"memoryBytes": 262144000,
"androidStartupMs": 2000,
"androidFrameRateFps": 60,
"androidFpsTarget": "mid-range",
"hermesOptimizations": {
"inlineStoreOnPut": true,
"allocationProfile": true,
"bytecodeCache": true
}
}
10 changes: 9 additions & 1 deletion scripts/check-performance-budget.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,17 @@ if (report.memoryMaxBytes > budget.memoryBytes) {
failures.push(`memory max ${report.memoryMaxBytes} bytes exceeds ${budget.memoryBytes} bytes`);
}

if (report.androidStartupMs && report.androidStartupMs > budget.androidStartupMs) {
failures.push(`Android startup ${report.androidStartupMs}ms exceeds ${budget.androidStartupMs}ms`);
}

if (report.androidFps && report.androidFps < budget.androidFrameRateFps) {
failures.push(`Android FPS ${report.androidFps}fps below target ${budget.androidFrameRateFps}fps`);
}

if (failures.length) {
console.error(`Performance budget failed:\n- ${failures.join('\n- ')}`);
process.exit(1);
}

console.log('Performance budget passed.');
console.log('Performance budget passed.');
Loading
Loading