Skip to content

Commit ef5cf66

Browse files
committed
merge main
2 parents bf40073 + aa5d2da commit ef5cf66

File tree

252 files changed

+6362
-2557
lines changed

Some content is hidden

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

252 files changed

+6362
-2557
lines changed

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

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,238 @@ 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+
- Inside the listener, at least one `setState` call directly mirrors an external value
533+
(e.g., `setWidth(window.innerWidth)`, `setOnline(navigator.onLine)`)
534+
- The pattern for that state variable follows: subscribe in setup, unsubscribe in cleanup,
535+
`setState(externalValue)` in the listener
536+
- Evaluate each state variable independently — if one state variable is a raw mirror of an
537+
external value, flag it even if the same effect also manages other state for different purposes
538+
539+
**DO NOT flag if:**
540+
541+
- The specific state variable being flagged undergoes transformation, debouncing, or derives
542+
from computation rather than directly mirroring the external value. Other state variables
543+
in the same effect that DO directly mirror external values should still be flagged.
544+
- The external API doesn't fit the `subscribe` / `getSnapshot` contract
545+
- The code already uses `useSyncExternalStore`
546+
- The subscription is managed by a library (e.g., Onyx's `useOnyx`)
547+
548+
- **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.
549+
550+
Good:
551+
552+
```tsx
553+
function subscribe(callback) {
554+
window.addEventListener('online', callback);
555+
window.addEventListener('offline', callback);
556+
return () => {
557+
window.removeEventListener('online', callback);
558+
window.removeEventListener('offline', callback);
559+
};
560+
}
561+
562+
function useOnlineStatus() {
563+
// ✅ Good: Subscribing to an external store with a built-in Hook
564+
return useSyncExternalStore(
565+
subscribe, // React won't resubscribe for as long as you pass the same function
566+
() => navigator.onLine, // How to get the value on the client
567+
() => true // How to get the value on the server
568+
);
569+
}
570+
571+
function ChatIndicator() {
572+
const isOnline = useOnlineStatus();
573+
// ...
574+
}
575+
```
576+
577+
Bad:
578+
579+
```tsx
580+
function useOnlineStatus() {
581+
// Not ideal: Manual store subscription in an Effect
582+
const [isOnline, setIsOnline] = useState(true);
583+
useEffect(() => {
584+
function updateState() {
585+
setIsOnline(navigator.onLine);
586+
}
587+
588+
updateState();
589+
590+
window.addEventListener('online', updateState);
591+
window.addEventListener('offline', updateState);
592+
return () => {
593+
window.removeEventListener('online', updateState);
594+
window.removeEventListener('offline', updateState);
595+
};
596+
}, []);
597+
return isOnline;
598+
}
599+
600+
function ChatIndicator() {
601+
const isOnline = useOnlineStatus();
602+
// ...
603+
}
604+
```
605+
606+
---
607+
608+
### [PERF-15] Clean up async Effects to prevent race conditions
609+
610+
- **Search patterns**: `useEffect`, `fetch(`, `async`, `await`, `.then(`, `setState`, `eslint-disable`
611+
612+
- **Condition**: Flag when EITHER of these is true:
613+
614+
**Case 1 — Missing cleanup:**
615+
- A `useEffect` performs async work (fetch, promise chain, async/await)
616+
- The async callback performs side effects (setState, navigation, data mutations, deletions)
617+
- There is no cleanup mechanism to discard stale responses (no `ignore` flag, no `AbortController`, no cancellation token)
618+
619+
**Case 2 — Suppressed dependency lint:**
620+
- A `useEffect` performs async work and triggers side effects (setState, navigation, mutations)
621+
- The dependency array has an `eslint-disable` comment suppressing `react-hooks/exhaustive-deps`
622+
- This hides a dependency that could change and cause a race condition
623+
624+
**DO NOT flag if:**
625+
626+
- The Effect includes an `ignore`/`cancelled` boolean checked before `setState`
627+
- The Effect uses `AbortController` to cancel the request on cleanup
628+
- The async operation is truly fire-and-forget (no setState, no navigation, no mutations —
629+
just logging or analytics that are safe to complete after unmount)
630+
- The dependency array is empty `[]` with no suppressed lint, AND the async callback only
631+
performs idempotent/safe operations (no navigation, no destructive mutations that could
632+
fire after unmount)
633+
- Data fetching is handled by a library/framework (e.g., Onyx, React Query)
634+
635+
- **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.
636+
637+
Good (ignore flag):
638+
639+
```tsx
640+
useEffect(() => {
641+
let ignore = false;
642+
643+
fetchResults(query).then((json) => {
644+
if (!ignore) {
645+
setResults(json);
646+
}
647+
});
648+
649+
return () => {
650+
ignore = true;
651+
};
652+
}, [query]);
653+
```
654+
655+
Good (AbortController):
656+
657+
```tsx
658+
useEffect(() => {
659+
const controller = new AbortController();
660+
661+
fetch(`/api/search?q=${query}`, {signal: controller.signal})
662+
.then((res) => res.json())
663+
.then((data) => setResults(data))
664+
.catch((e) => {
665+
if (e.name !== 'AbortError') {
666+
setError(e);
667+
}
668+
});
669+
670+
return () => controller.abort();
671+
}, [query]);
672+
```
673+
674+
Bad:
675+
676+
```tsx
677+
useEffect(() => {
678+
fetchResults(query).then((json) => {
679+
setResults(json);
680+
});
681+
}, [query]);
682+
```
683+
684+
---
685+
686+
### [PERF-16] Guard initialization logic against double-execution
687+
688+
- **Search patterns**: `useEffect` with empty dependency array `[]` containing initialization logic (API calls, storage access, authentication checks, SDK setup, global configuration, event handler registration)
689+
690+
- **Condition**: Flag ONLY when ALL of these are true:
691+
692+
- A `useEffect` with empty dependency array `[]` runs non-idempotent initialization logic
693+
- 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)
694+
- There is no guard mechanism (module-level flag or module-level execution)
695+
- This applies at any level — app-wide init, feature screens, or individual components
696+
697+
**DO NOT flag if:**
698+
699+
- A module-level or ref-based guard variable prevents double execution. A proper execution
700+
guard follows this pattern: `if (didInit) return; didInit = true;` — it checks a flag AND
701+
sets it. Conditional checks on data/props (e.g., `if (!transaction) return`,
702+
`if (action !== 'CREATE') return`) are NOT execution guards — they validate preconditions
703+
but don't prevent the logic from running again if the same preconditions hold in a second
704+
invocation (which happens in React Strict Mode).
705+
- The logic is idempotent (safe to run twice with no side effects)
706+
- NOTE: Navigation calls (e.g., `navigate()`), data deletion (e.g., `removeDraftTransactions()`),
707+
and similar mutations are NOT idempotent — running them twice produces different/undesirable results.
708+
- The logic is at module level, outside any component
709+
- The Effect has non-empty dependencies (not one-time init)
710+
711+
- **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.
712+
713+
Good (module-level guard):
714+
715+
```tsx
716+
let didInit = false;
717+
718+
function App() {
719+
useEffect(() => {
720+
if (didInit) {
721+
return;
722+
}
723+
didInit = true;
724+
725+
loadDataFromLocalStorage();
726+
checkAuthToken();
727+
}, []);
728+
}
729+
```
730+
731+
Good (module-level execution):
732+
733+
```tsx
734+
if (typeof window !== 'undefined') {
735+
checkAuthToken();
736+
loadDataFromLocalStorage();
737+
}
738+
739+
function App() {
740+
// ...
741+
}
742+
```
743+
744+
Bad:
745+
746+
```tsx
747+
function App() {
748+
useEffect(() => {
749+
loadDataFromLocalStorage();
750+
checkAuthToken();
751+
}, []);
752+
}
753+
```
754+
755+
---
756+
525757
### [CONSISTENCY-1] Avoid platform-specific checks within components
526758

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

.github/scripts/inject-ci-data.sh

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/bin/bash
2+
3+
# This script injects CI data into src/CONST/CI.ts so the JS bundle
4+
# carries the correct values even when native builds are cached/reused.
5+
#
6+
# Usage: ./.github/scripts/inject-ci-data.sh KEY=VALUE [KEY=VALUE ...]
7+
#
8+
# Examples:
9+
# ./.github/scripts/inject-ci-data.sh PULL_REQUEST_NUMBER=12345
10+
# ./.github/scripts/inject-ci-data.sh PULL_REQUEST_NUMBER=12345 SOME_OTHER=value
11+
#
12+
# - If no arguments are provided, exits 0 (no crash).
13+
# - If CI.ts is not found, logs a warning and exits 0 (no crash).
14+
# - Values must be alphanumeric (plus hyphens, underscores, dots) for safety.
15+
# - Each KEY must have a matching "const KEY = '...'" line in CI.ts.
16+
17+
set -euo pipefail
18+
19+
CI_FILE="src/CONST/CI.ts"
20+
21+
echo "[inject-ci-data] Injecting CI data into $CI_FILE"
22+
23+
if [ "$#" -eq 0 ]; then
24+
echo "[inject-ci-data] No arguments provided. Skipping injection."
25+
exit 0
26+
fi
27+
28+
if [ ! -f "$CI_FILE" ]; then
29+
echo "[inject-ci-data] ⚠️ $CI_FILE not found. Skipping injection."
30+
exit 0
31+
fi
32+
33+
for ARG in "$@"; do
34+
if [[ "$ARG" != *"="* ]]; then
35+
echo "[inject-ci-data] ⚠️ Invalid argument '$ARG'. Expected KEY=VALUE format. Skipping."
36+
continue
37+
fi
38+
39+
KEY="${ARG%%=*}"
40+
VALUE="${ARG#*=}"
41+
42+
if [ -z "$KEY" ]; then
43+
echo "[inject-ci-data] ⚠️ Empty key in '$ARG'. Skipping."
44+
continue
45+
fi
46+
47+
if [ -z "$VALUE" ]; then
48+
echo "[inject-ci-data] No value for '$KEY'. Skipping."
49+
continue
50+
fi
51+
52+
if ! [[ "$VALUE" =~ ^[a-zA-Z0-9._-]+$ ]]; then
53+
echo "[inject-ci-data] ⚠️ Value '$VALUE' for key '$KEY' contains invalid characters. Skipping."
54+
continue
55+
fi
56+
57+
BEFORE=$(grep "const ${KEY} " "$CI_FILE" || true)
58+
59+
if [ -z "$BEFORE" ]; then
60+
echo "[inject-ci-data] ⚠️ Could not find 'const ${KEY}' in $CI_FILE. Skipping."
61+
continue
62+
fi
63+
64+
# Works on both GNU sed (Linux/CI) and BSD sed (macOS) by using a backup
65+
# extension and then removing the backup file.
66+
sed -i.bak "s/const ${KEY} = '.*'/const ${KEY} = '${VALUE}'/" "$CI_FILE"
67+
rm -f "${CI_FILE}.bak"
68+
69+
AFTER=$(grep "const ${KEY} " "$CI_FILE" || true)
70+
echo "[inject-ci-data] ✅ Injection for $KEY successful: $AFTER"
71+
done
72+
73+
echo "[inject-ci-data] Done."

.github/workflows/testBuild.yml

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,13 @@ jobs:
192192
with:
193193
ref: ${{ needs.prep.outputs.APP_REF }}
194194

195-
- name: Create .env.adhoc file based on staging and add PULL_REQUEST_NUMBER env to it
195+
- name: Create .env.adhoc file based on staging
196196
run: |
197197
cp .env.staging .env.adhoc
198198
sed -i 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc
199-
echo "PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc
199+
200+
- name: Inject CI data into JS bundle
201+
run: ./.github/scripts/inject-ci-data.sh PULL_REQUEST_NUMBER="$PULL_REQUEST_NUMBER"
200202

201203
- name: Setup Node
202204
uses: ./.github/actions/composite/setupNode
@@ -257,11 +259,13 @@ jobs:
257259
cd Mobile-Expensify
258260
npm run grunt:build:shared
259261
260-
- name: Setup dotenv
262+
- name: Create .env.adhoc file based on staging
261263
run: |
262-
cp .env.staging .env.adhoc
263-
sed -i 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc
264-
echo "APP_PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc
264+
cp .env.staging .env.adhoc
265+
sed -i 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc
266+
267+
- name: Inject CI data into JS bundle
268+
run: ./.github/scripts/inject-ci-data.sh PULL_REQUEST_NUMBER="$PULL_REQUEST_NUMBER"
265269

266270
- name: Setup 1Password CLI and certificates
267271
uses: Expensify/GitHub-Actions/setup-certificate-1p@main
@@ -370,11 +374,13 @@ jobs:
370374
with:
371375
IS_HYBRID_BUILD: 'true'
372376

373-
- name: Create .env.adhoc file based on staging and add PULL_REQUEST_NUMBER env to it
377+
- name: Create .env.adhoc file based on staging
374378
run: |
375379
cp .env.staging .env.adhoc
376380
sed -i '' 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc
377-
echo "PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc
381+
382+
- name: Inject CI data into JS bundle
383+
run: ./.github/scripts/inject-ci-data.sh PULL_REQUEST_NUMBER="$PULL_REQUEST_NUMBER"
378384

379385
- name: Setup 1Password CLI and certificates
380386
uses: Expensify/GitHub-Actions/setup-certificate-1p@main

0 commit comments

Comments
 (0)