Skip to content

Commit 9eb91c1

Browse files
Merge branch 'main' of github.com:Expensify/App into stites-combineInvoicingPage
2 parents 0073947 + 0544fa3 commit 9eb91c1

File tree

234 files changed

+5396
-2341
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

234 files changed

+5396
-2341
lines changed

.claude/agents/code-inline-reviewer.md

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,222 @@ const results = items.map((item) => {
522522

523523
---
524524

525+
### [PERF-14] Use `useSyncExternalStore` for external store subscriptions
526+
527+
- **Search patterns**: `addEventListener`, `subscribe`, `useEffect`, `useState`
528+
529+
- **Condition**: Flag ONLY when ALL of these are true:
530+
531+
- A `useEffect` subscribes to an external source (DOM events, third-party store, browser API)
532+
- The Effect's only purpose is to read a value from that source and write it into state via `setState`
533+
- The pattern follows: subscribe in setup, unsubscribe in cleanup, `setState` in the listener
534+
535+
**DO NOT flag if:**
536+
537+
- The subscription triggers side effects beyond reading a value (e.g., logging, navigation)
538+
- The external API doesn't fit the `subscribe` / `getSnapshot` contract
539+
- The code already uses `useSyncExternalStore`
540+
- The subscription is managed by a library (e.g., Onyx's `useOnyx`)
541+
542+
- **Reasoning**: [`useSyncExternalStore`](https://react.dev/learn/you-might-not-need-an-effect#subscribing-to-an-external-store) is React's purpose-built hook for reading external store values. It handles concurrent rendering edge cases (tearing), avoids the extra render pass of `useEffect` + `setState`, and makes the subscription contract explicit. Manual subscriptions via `useEffect` are more error-prone and miss these guarantees.
543+
544+
Good:
545+
546+
```tsx
547+
function subscribe(callback) {
548+
window.addEventListener('online', callback);
549+
window.addEventListener('offline', callback);
550+
return () => {
551+
window.removeEventListener('online', callback);
552+
window.removeEventListener('offline', callback);
553+
};
554+
}
555+
556+
function useOnlineStatus() {
557+
// ✅ Good: Subscribing to an external store with a built-in Hook
558+
return useSyncExternalStore(
559+
subscribe, // React won't resubscribe for as long as you pass the same function
560+
() => navigator.onLine, // How to get the value on the client
561+
() => true // How to get the value on the server
562+
);
563+
}
564+
565+
function ChatIndicator() {
566+
const isOnline = useOnlineStatus();
567+
// ...
568+
}
569+
```
570+
571+
Bad:
572+
573+
```tsx
574+
function useOnlineStatus() {
575+
// Not ideal: Manual store subscription in an Effect
576+
const [isOnline, setIsOnline] = useState(true);
577+
useEffect(() => {
578+
function updateState() {
579+
setIsOnline(navigator.onLine);
580+
}
581+
582+
updateState();
583+
584+
window.addEventListener('online', updateState);
585+
window.addEventListener('offline', updateState);
586+
return () => {
587+
window.removeEventListener('online', updateState);
588+
window.removeEventListener('offline', updateState);
589+
};
590+
}, []);
591+
return isOnline;
592+
}
593+
594+
function ChatIndicator() {
595+
const isOnline = useOnlineStatus();
596+
// ...
597+
}
598+
```
599+
600+
---
601+
602+
### [PERF-15] Clean up async Effects to prevent race conditions
603+
604+
- **Search patterns**: `useEffect`, `fetch(`, `async`, `await`, `.then(`, `setState`, `eslint-disable`
605+
606+
- **Condition**: Flag when EITHER of these is true:
607+
608+
**Case 1 — Missing cleanup:**
609+
- A `useEffect` performs async work (fetch, promise chain, async/await)
610+
- The async result is written to state via `setState`
611+
- There is no cleanup mechanism to discard stale responses (no `ignore` flag, no `AbortController`, no cancellation token)
612+
613+
**Case 2 — Suppressed dependency lint:**
614+
- A `useEffect` performs async work and sets state
615+
- The dependency array has an `eslint-disable` comment suppressing `react-hooks/exhaustive-deps`
616+
- This hides a dependency that could change and cause a race condition
617+
618+
**DO NOT flag if:**
619+
620+
- The Effect includes an `ignore`/`cancelled` boolean checked before `setState`
621+
- The Effect uses `AbortController` to cancel the request on cleanup
622+
- The dependency array is empty `[]` with no suppressed lint (no race possible — deps never change)
623+
- The async operation doesn't set state (fire-and-forget)
624+
- Data fetching is handled by a library/framework (e.g., Onyx, React Query)
625+
626+
- **Reasoning**: When an Effect's dependencies change, the previous async operation [may still be in flight](https://react.dev/learn/you-might-not-need-an-effect#fetching-data). Without cleanup, a slow earlier response can overwrite the result of a faster later response, showing stale data. This is especially dangerous for search inputs and navigation where dependencies change rapidly.
627+
628+
Good (ignore flag):
629+
630+
```tsx
631+
useEffect(() => {
632+
let ignore = false;
633+
634+
fetchResults(query).then((json) => {
635+
if (!ignore) {
636+
setResults(json);
637+
}
638+
});
639+
640+
return () => {
641+
ignore = true;
642+
};
643+
}, [query]);
644+
```
645+
646+
Good (AbortController):
647+
648+
```tsx
649+
useEffect(() => {
650+
const controller = new AbortController();
651+
652+
fetch(`/api/search?q=${query}`, {signal: controller.signal})
653+
.then((res) => res.json())
654+
.then((data) => setResults(data))
655+
.catch((e) => {
656+
if (e.name !== 'AbortError') {
657+
setError(e);
658+
}
659+
});
660+
661+
return () => controller.abort();
662+
}, [query]);
663+
```
664+
665+
Bad:
666+
667+
```tsx
668+
useEffect(() => {
669+
fetchResults(query).then((json) => {
670+
setResults(json);
671+
});
672+
}, [query]);
673+
```
674+
675+
---
676+
677+
### [PERF-16] Guard initialization logic against double-execution
678+
679+
- **Search patterns**: `useEffect` with empty dependency array `[]` containing initialization logic (API calls, storage access, authentication checks, SDK setup, global configuration, event handler registration)
680+
681+
- **Condition**: Flag ONLY when ALL of these are true:
682+
683+
- A `useEffect` with empty dependency array `[]` runs non-idempotent initialization logic
684+
- The logic would cause problems if executed twice (e.g., double API calls, invalidated tokens, duplicate SDK init, duplicate analytics sessions, duplicate deep link registrations)
685+
- There is no guard mechanism (module-level flag or module-level execution)
686+
- This applies at any level — app-wide init, feature screens, or individual components
687+
688+
**DO NOT flag if:**
689+
690+
- A module-level guard variable prevents double execution (`if (didInit) return`)
691+
- The logic is idempotent (safe to run twice with no side effects)
692+
- The logic is at module level, outside any component
693+
- The Effect has non-empty dependencies (not one-time init)
694+
695+
- **Reasoning**: React Strict Mode [intentionally double-invokes Effects](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application) in development to surface missing cleanup. Non-idempotent initialization — whether app-wide (auth tokens, global config) or per-feature (SDK setup, analytics session creation, deep link handler registration) — can break when executed twice. Guarding with a module-level flag or moving initialization outside the component ensures it runs exactly once regardless of rendering mode.
696+
697+
Good (module-level guard):
698+
699+
```tsx
700+
let didInit = false;
701+
702+
function App() {
703+
useEffect(() => {
704+
if (didInit) {
705+
return;
706+
}
707+
didInit = true;
708+
709+
loadDataFromLocalStorage();
710+
checkAuthToken();
711+
}, []);
712+
}
713+
```
714+
715+
Good (module-level execution):
716+
717+
```tsx
718+
if (typeof window !== 'undefined') {
719+
checkAuthToken();
720+
loadDataFromLocalStorage();
721+
}
722+
723+
function App() {
724+
// ...
725+
}
726+
```
727+
728+
Bad:
729+
730+
```tsx
731+
function App() {
732+
useEffect(() => {
733+
loadDataFromLocalStorage();
734+
checkAuthToken();
735+
}, []);
736+
}
737+
```
738+
739+
---
740+
525741
### [CONSISTENCY-1] Avoid platform-specific checks within components
526742

527743
- **Search patterns**: `Platform.OS`, `isAndroid`, `isIOS`, `Platform\.select`

.github/ISSUE_TEMPLATE/Standard.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
name: Standard issue template
33
about: A standard template to follow when creating a new issue in this repository
4-
labels: Bug, Daily
4+
labels: Bug, Daily, External
55
---
66

77
If you haven’t already, check out our [contributing guidelines](https://github.com/Expensify/ReactNativeChat/blob/main/contributingGuides/CONTRIBUTING.md) for onboarding and email contributors@expensify.com to request to join our Slack channel!

.github/workflows/cherryPick.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,12 @@ jobs:
116116
VERSION_BUMP_COMMIT="$(git log -1 --format='%H' --author='OSBotify' --grep 'Update version to ${{ needs.createNewVersion.outputs.NEW_VERSION }}')"
117117
if [ -z "$VERSION_BUMP_COMMIT" ]; then
118118
echo "::error::❌ Could not find E/App version bump commit for ${{ needs.createNewVersion.outputs.NEW_VERSION }}"
119-
git log --oneline
120-
else
121-
echo "::notice::👀 Found E/App version bump commit $VERSION_BUMP_COMMIT"
119+
echo "::error::This may indicate E/App and Mobile-Expensify versions are out of sync"
120+
echo "::error::Run the syncVersions workflow to fix, then retry this cherry-pick"
121+
git log --oneline -20
122+
exit 1
122123
fi
124+
echo "::notice::👀 Found E/App version bump commit $VERSION_BUMP_COMMIT"
123125
echo "VERSION_BUMP_SHA=$VERSION_BUMP_COMMIT" >> "$GITHUB_OUTPUT"
124126
125127
- name: Get Mobile-Expensify version bump commit
@@ -130,10 +132,12 @@ jobs:
130132
VERSION_BUMP_COMMIT="$(git log -1 --format='%H' --author='OSBotify' --grep 'Update version to ${{ needs.createNewVersion.outputs.NEW_VERSION }}')"
131133
if [ -z "$VERSION_BUMP_COMMIT" ]; then
132134
echo "::error::❌ Could not find Mobile-Expensify version bump commit for ${{ needs.createNewVersion.outputs.NEW_VERSION }}"
133-
git log --oneline
134-
else
135-
echo "::notice::👀 Found Mobile-Expensify version bump commit $VERSION_BUMP_COMMIT"
135+
echo "::error::This may indicate E/App and Mobile-Expensify versions are out of sync"
136+
echo "::error::Run the syncVersions workflow to fix, then retry this cherry-pick"
137+
git log --oneline -20
138+
exit 1
136139
fi
140+
echo "::notice::👀 Found Mobile-Expensify version bump commit $VERSION_BUMP_COMMIT"
137141
echo "VERSION_BUMP_SHA=$VERSION_BUMP_COMMIT" >> "$GITHUB_OUTPUT"
138142
139143
- name: Get merge commit for pull request to CP

.github/workflows/createNewVersion.yml

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,23 +127,50 @@ jobs:
127127
run: |
128128
git add Mobile-Expensify
129129
git commit -m "Update Mobile-Expensify submodule version to ${{ steps.bumpVersion.outputs.NEW_VERSION }}"
130-
if ! git push origin main; then
131-
echo "Race condition! E/App main was updated while this workflow was running, so push failed. Fetching remote, rebasing, and retrying push once."
130+
131+
MAX_RETRIES=5
132+
RETRY_DELAY=2
133+
134+
for i in $(seq 1 $MAX_RETRIES); do
135+
if git push origin main; then
136+
echo "::notice::Successfully pushed E/App changes on attempt $i"
137+
exit 0
138+
fi
139+
140+
echo "::warning::Push attempt $i failed, retrying in ${RETRY_DELAY}s..."
141+
sleep $RETRY_DELAY
142+
RETRY_DELAY=$((RETRY_DELAY * 2))
143+
132144
if ! git fetch origin main; then
133-
echo "::error:: ❌ Unable to fetch main"
134-
echo "::error:: This can happen when Mobile-Expensify and E/App repos got out of sync."
135-
echo "::error:: We likely need to manually bump the version in E/App to match Mobile-Expensify."
136-
exit 1
145+
echo "::error::Unable to fetch main on attempt $i"
146+
continue
137147
fi
148+
138149
if ! git rebase origin/main; then
139-
echo "::error:: Rebase failed while retrying E/App push"
140-
exit 1
141-
fi
142-
if ! git push origin main; then
143-
echo "::error:: E/App change failed to push after rebase"
144-
exit 1
150+
echo "::warning::Rebase failed on attempt $i, aborting and retrying..."
151+
git rebase --abort || true
152+
continue
145153
fi
154+
done
155+
156+
echo "::error::E/App push failed after $MAX_RETRIES attempts"
157+
echo "::error::CRITICAL: Mobile-Expensify has version ${{ steps.bumpVersion.outputs.NEW_VERSION }} but E/App does not!"
158+
echo "::error::Manual intervention required - run the syncVersions workflow to fix"
159+
exit 1
160+
161+
- name: Verify version sync
162+
run: |
163+
APP_VERSION=$(jq -r .version package.json)
164+
ME_VERSION=$(jq -r .meta.version Mobile-Expensify/app/config/config.json)
165+
166+
if [ "$APP_VERSION" != "$ME_VERSION" ]; then
167+
echo "::error::Version mismatch detected after push!"
168+
echo "::error::E/App version: $APP_VERSION"
169+
echo "::error::Mobile-Expensify version: $ME_VERSION"
170+
echo "::error::Run the syncVersions workflow to fix this"
171+
exit 1
146172
fi
173+
echo "::notice::Versions verified: $APP_VERSION"
147174
148175
- name: Announce failed workflow in Slack
149176
if: ${{ failure() }}

0 commit comments

Comments
 (0)