Skip to content
Open
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
2 changes: 1 addition & 1 deletion .rnstorybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const preview: Preview = {
},
},
decorators: [
(Story) => (
Story => (
<SafeAreaProvider>
<AuthProvider>
<AnalyticsProvider>
Expand Down
2 changes: 1 addition & 1 deletion .rnstorybook/stories/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const meta = {
title: 'Example/Button',
component: Button,
decorators: [
(Story) => (
Story => (
<View style={{ flex: 1, alignItems: 'flex-start' }}>
<Story />
</View>
Expand Down
7 changes: 3 additions & 4 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ SplashScreen.preventAutoHideAsync();
// SHOW_STORYBOOK flag based on environment variable
const SHOW_STORYBOOK = process.env.EXPO_PUBLIC_STORYBOOK === 'true';


// Centralized structured logging initialized on startup
requireEnvVariables();

Expand All @@ -56,7 +55,7 @@ if (__DEV__) {
}

const App = () => {
const theme = useAppStore((state) => state.theme);
const theme = useAppStore(state => state.theme);
useAdaptiveTheme();

const appStateRef = useRef<AppStateStatus>(AppState.currentState);
Expand Down Expand Up @@ -98,7 +97,7 @@ const App = () => {
crashReportingService.init();

// Initialize secure storage (Keychain/Keystore) for encrypted token storage
initializeSecureStorage().catch((error) => {
initializeSecureStorage().catch(error => {
logger.error('Failed to initialize secure storage:', error);
// Continue app startup even if secure storage init fails
// (user will be prompted to re-authenticate if needed)
Expand All @@ -121,7 +120,7 @@ const App = () => {
socketService.connect();

// Initialize push notifications: request permissions and get device token
registerForPushNotifications().then(async (token) => {
registerForPushNotifications().then(async token => {
if (token) {
const { setPushToken, setTokenRegistered } = useNotificationStore.getState();
setPushToken(token);
Expand Down
11 changes: 10 additions & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,16 @@
}
],
"expo-speech-recognition",
"expo-video"
"expo-video",
"./plugins/withProguard.js",
[
"expo-build-properties",
{
"android": {
"enableProguardInReleaseBuilds": true
}
}
]
],
"notification": {
"icon": "./assets/notification-icon.png",
Expand Down
4 changes: 2 additions & 2 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@

import { HomeScreenSkeleton } from '@/components/mobile/HomeScreenSkeleton';
import { useAnalytics } from '@/hooks';
import { useAppStore } from '@/store';

Check failure on line 6 in app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / ci

Unable to resolve path to module '@/store'
import { createLazyRoute } from '@/utils/lazyRoute';

Check failure on line 7 in app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / ci

Unable to resolve path to module '@/utils/lazyRoute'
import { ScreenName } from '@/utils/trackingEvents';

Check failure on line 8 in app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / ci

Unable to resolve path to module '@/utils/trackingEvents'

const LazyHomeScreenContent = createLazyRoute({
importFn: () =>
import('@/screens/HomeScreenContent').then((m) => ({ default: m.HomeScreenContent })),
import('@/screens/HomeScreenContent').then(m => ({ default: m.HomeScreenContent })),

Check failure on line 12 in app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / ci

Unable to resolve path to module '@/screens/HomeScreenContent'
LoadingFallback: HomeScreenSkeleton,
boundaryName: 'HomeRoute',
});
Expand Down Expand Up @@ -50,7 +50,7 @@
useEffect(() => {
const cleanup = fetchHomeData();
return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps -- simulated home fetch runs once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps -- simulated home fetch runs once on mount
}, []);

if (isLoading) {
Expand Down
10 changes: 4 additions & 6 deletions app/(tabs)/profile.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { useEffect, useState } from 'react';

import { ProfileSkeleton } from '@/components/mobile/ProfileSkeleton';
import { useAppStore } from '@/store';

Check failure on line 4 in app/(tabs)/profile.tsx

View workflow job for this annotation

GitHub Actions / ci

Unable to resolve path to module '@/store'
import { createLazyRoute } from '@/utils/lazyRoute';

Check failure on line 5 in app/(tabs)/profile.tsx

View workflow job for this annotation

GitHub Actions / ci

Unable to resolve path to module '@/utils/lazyRoute'

const LazyMobileProfile = createLazyRoute({
importFn: () =>
import('@/components/mobile/MobileProfile').then((m) => ({ default: m.MobileProfile })),
import('@/components/mobile/MobileProfile').then(m => ({ default: m.MobileProfile })),
LoadingFallback: ProfileSkeleton,
boundaryName: 'ProfileTabRoute',
});

const ProfileTab = () => {
const theme = useAppStore((s) => s.theme);
const user = useAppStore((s) => s.user);
const theme = useAppStore(s => s.theme);
const user = useAppStore(s => s.user);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
Expand All @@ -23,9 +23,7 @@

const userId = user?.id ?? '123';

return (
<LazyMobileProfile userId={userId} isDark={theme === 'dark'} isLoading={isLoading} />
);
return <LazyMobileProfile userId={userId} isDark={theme === 'dark'} isLoading={isLoading} />;
};

export default ProfileTab;
11 changes: 3 additions & 8 deletions app/(tabs)/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,13 @@
import { useEffect, useState } from 'react';
import { Alert, StyleSheet, View } from 'react-native';

import {
CourseCardSkeleton,
SearchResultItem,
SearchScreenSkeleton,
Skeleton,
} from '@/components';
import { CourseCardSkeleton, SearchResultItem, SearchScreenSkeleton, Skeleton } from '@/components';
import { sampleCourse } from '@/data/sampleCourse';

Check failure on line 6 in app/(tabs)/search.tsx

View workflow job for this annotation

GitHub Actions / ci

Unable to resolve path to module '@/data/sampleCourse'
import { createLazyRoute } from '@/utils/lazyRoute';

Check failure on line 7 in app/(tabs)/search.tsx

View workflow job for this annotation

GitHub Actions / ci

Unable to resolve path to module '@/utils/lazyRoute'

const LazyMobileSearch = createLazyRoute({
importFn: () =>
import('@/components/mobile/MobileSearch').then((m) => ({ default: m.MobileSearch })),
import('@/components/mobile/MobileSearch').then(m => ({ default: m.MobileSearch })),
LoadingFallback: SearchScreenSkeleton,
boundaryName: 'SearchRoute',
});
Expand Down Expand Up @@ -46,7 +41,7 @@
useEffect(() => {
const cleanup = fetchSearchData();
return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps -- simulated search fetch runs once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps -- simulated search fetch runs once on mount
}, []);

const handleResultPress = (item: SearchResultItem) => {
Expand Down
24 changes: 24 additions & 0 deletions docs/PERFORMANCE_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,41 @@ This document describes the performance regression testing framework for teachLi
Each heavy component has defined performance budgets:

### MobileSearch

- **Max Render Time**: 500ms
- **Max Memory Increase**: 10MB
- **Regression Threshold**: 10%

### VirtualList

- **Max Render Time**: 300ms
- **Max Memory Increase**: 5MB
- **Regression Threshold**: 10%

### AdvancedDataGrid

- **Max Render Time**: 600ms
- **Max Memory Increase**: 15MB
- **Regression Threshold**: 10%

## Running Performance Tests

### Run all performance tests

```bash
npm test -- --testPathPattern=perf
```

### Run performance tests for a specific component

```bash
npm test -- MobileSearch.perf.test.tsx
npm test -- VirtualList.perf.test.tsx
npm test -- AdvancedDataGrid.perf.test.tsx
```

### Run with coverage

```bash
npm run test:coverage -- --testPathPattern=perf
```
Expand All @@ -53,18 +59,21 @@ Each test measures:
## Test Scenarios

### MobileSearch

- Basic render within budget
- Regression detection (10% threshold)
- Rapid prop changes
- Large search result sets

### VirtualList

- Rendering 100 items
- Rendering 500 items
- Rendering 1000 items
- Fixed item height optimization

### AdvancedDataGrid

- Rendering 50 rows
- Rendering 200 rows
- Rendering 500 rows
Expand All @@ -75,18 +84,21 @@ Each test measures:
## CI/CD Integration

Performance tests run automatically on:

- Pull requests to `main`
- Pushes to `main`
- Manual trigger via GitHub Actions

Tests fail if:

- Any component exceeds its performance budget
- Regression exceeds 10% threshold
- Memory usage exceeds limits

## Adding New Performance Tests

### 1. Create a new test file

```typescript
// tests/components/MyComponent.perf.test.tsx
import { render } from '@testing-library/react-native';
Expand All @@ -111,33 +123,41 @@ describe('MyComponent Performance Tests', () => {
```

### 2. Define performance budgets

- Set realistic budgets based on component complexity
- Consider device capabilities (mobile devices)
- Leave headroom for future features

### 3. Test with various prop counts

- Test with minimum data
- Test with typical data
- Test with maximum expected data

## Performance Utilities

### `measureRenderTime(fn: () => void): number`

Measures the time taken to execute a function.

### `getMemoryUsage(): number`

Gets current heap memory usage.

### `measurePerformance(fn: () => void): PerformanceMetrics`

Measures both render time and memory usage.

### `checkPerformanceBudget(metrics, budget): { passed, violations }`

Checks if metrics exceed budget limits.

### `detectRegression(current, baseline, threshold): { isRegression, percentChange, message }`

Detects performance regressions compared to baseline.

### `measureAveragePerformance(fn, iterations): PerformanceMetrics`

Runs a function multiple times and returns average metrics.

## Best Practices
Expand All @@ -152,16 +172,19 @@ Runs a function multiple times and returns average metrics.
## Troubleshooting

### Tests are flaky

- Increase iterations in `measureAveragePerformance`
- Check for background processes affecting performance
- Run tests in isolation: `npm test -- --runInBand`

### Memory usage is high

- Check for memory leaks in component
- Verify mocks are properly cleaning up
- Use `useMemoryMonitor` hook for debugging

### Regression detected

- Profile the component with React DevTools
- Check for unnecessary re-renders
- Review recent changes to the component
Expand All @@ -176,6 +199,7 @@ Runs a function multiple times and returns average metrics.
## Team Training

All team members should:

1. Understand performance budgets for their components
2. Run performance tests before submitting PRs
3. Investigate regressions > 10%
Expand Down
6 changes: 5 additions & 1 deletion eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
"autoIncrement": true
},
"android": {
"autoIncrement": true
"autoIncrement": true,
"buildType": "apk"
},
"env": {
"NODE_ENV": "production"
},
"channel": "production"
}
Expand Down
28 changes: 10 additions & 18 deletions metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,7 @@ function analyzeRouteChunkSizes(graph) {
if (modulePath.includes('node_modules')) continue;
if (!/\.(tsx?|jsx?)$/.test(modulePath)) continue;

const { bytes, moduleCount } = computeRouteSyncChunkSize(
modulePath,
graph.dependencies,
);
const { bytes, moduleCount } = computeRouteSyncChunkSize(modulePath, graph.dependencies);

const route = path.relative(projectRoot, modulePath);
const exceeds = bytes > ROUTE_SIZE_THRESHOLD;
Expand All @@ -87,7 +84,7 @@ function analyzeRouteChunkSizes(graph) {
`\n⚠️ [auto-split] Route chunk exceeds 100 KB threshold\n` +
` Route : ${route}\n` +
` Sync size : ${kb} KB (${moduleCount} modules)\n` +
` Fix : use React.lazy() for heavy component imports\n`,
` Fix : use React.lazy() for heavy component imports\n`
);
}
}
Expand All @@ -104,8 +101,8 @@ function analyzeRouteChunkSizes(graph) {
routes: results,
},
null,
2,
),
2
)
);
} catch {
// Non-fatal: report write failure must not break the build
Expand All @@ -121,12 +118,7 @@ function analyzeRouteChunkSizes(graph) {
* @returns {Function}
*/
function wrapWithRouteSizeAnalyzer(existingSerializer) {
return async function routeSizeAnalyzerSerializer(
entryPoint,
preModules,
graph,
options,
) {
return async function routeSizeAnalyzerSerializer(entryPoint, preModules, graph, options) {
try {
analyzeRouteChunkSizes(graph);
} catch (err) {
Expand All @@ -142,10 +134,10 @@ function wrapWithRouteSizeAnalyzer(existingSerializer) {
// No upstream serializer — call Metro's built-in bundler directly.
// These module paths are stable across Metro 0.76–0.82 (Expo SDK 50–54).
const { default: baseJSBundle } = require('metro/src/DeltaBundler/Serializers/baseJSBundle');
const { default: bundleToString } = require('metro/src/DeltaBundler/Serializers/bundleToString');
const { code } = bundleToString(
baseJSBundle(entryPoint, preModules, graph, options),
);
const {
default: bundleToString,
} = require('metro/src/DeltaBundler/Serializers/bundleToString');
const { code } = bundleToString(baseJSBundle(entryPoint, preModules, graph, options));
return code;
};
}
Expand Down Expand Up @@ -218,7 +210,7 @@ const nativewindConfig = withNativeWind(config, { input: './global.css' });
// Inject the route size analysis observer into the serializer chain.
nativewindConfig.serializer ??= {};
nativewindConfig.serializer.customSerializer = wrapWithRouteSizeAnalyzer(
nativewindConfig.serializer.customSerializer,
nativewindConfig.serializer.customSerializer
);

module.exports = nativewindConfig;
Loading
Loading