Skip to content

Commit f58ca83

Browse files
Merge branch 'main' into helpsite-rename-perk
2 parents 3e99b05 + 26ccc41 commit f58ca83

786 files changed

Lines changed: 32827 additions & 7435 deletions

File tree

Some content is hidden

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

.claude/skills/coding-standards/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Coding standards for the Expensify App. Each standard is a standalone file in `r
4444
- [CONSISTENCY-6](rules/consistency-6-proper-error-handling.md) — Proper error handling
4545

4646
### Clean React Patterns
47+
- [CLEAN-REACT-PATTERNS-0](rules/clean-react-0-compiler.md) — React Compiler compliance
4748
- [CLEAN-REACT-PATTERNS-1](rules/clean-react-1-composition-over-config.md) — Composition over configuration
4849
- [CLEAN-REACT-PATTERNS-2](rules/clean-react-2-own-behavior.md) — Components own their behavior
4950
- [CLEAN-REACT-PATTERNS-3](rules/clean-react-3-context-free-contracts.md) — Context-free component contracts
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
---
2+
ruleId: CLEAN-REACT-PATTERNS-0
3+
title: React Compiler compliance
4+
---
5+
6+
## [CLEAN-REACT-PATTERNS-0] React Compiler compliance
7+
8+
### Reasoning
9+
10+
React Compiler is enabled in this codebase (`babel-plugin-react-compiler` runs first in both webpack and metro configs). It automatically memoizes components and hooks at the AST level — analyzing data flow, tracking dependencies, and inserting fine-grained caching that is more precise than any hand-written `useMemo`, `useCallback`, or `React.memo`.
11+
12+
Manual memoization is therefore:
13+
14+
1. **Redundant** — the compiler already handles it, so the manual wrapper adds zero value
15+
2. **Harmful** — it interferes with the compiler's optimization model, potentially preventing it from applying its own caching strategy or causing double-wrapping
16+
3. **Noisy** — it clutters the codebase with dependency arrays that must be maintained, reviewed, and debugged
17+
18+
The codebase enforces this via:
19+
- **Babel plugin**: `babel-plugin-react-compiler` in `babel.config.js`
20+
- **ESLint processor**: `eslint-plugin-react-compiler-compat` suppresses redundant lint rules when files compile successfully
21+
- **CI compliance check**: `scripts/react-compiler-compliance-check.ts` blocks PRs with manual memoization in new files
22+
23+
Reference: [React Compiler documentation](https://react.dev/learn/react-compiler)
24+
25+
### Incorrect
26+
27+
#### Incorrect (useCallback)
28+
29+
```tsx
30+
function ReportScreen({reportID}: {reportID: string}) {
31+
const handlePress = useCallback(() => {
32+
Navigation.navigate(ROUTES.REPORT_DETAILS.getRoute(reportID));
33+
}, [reportID]);
34+
35+
return <Button onPress={handlePress} />;
36+
}
37+
```
38+
39+
#### Incorrect (useMemo)
40+
41+
```tsx
42+
function PolicyList({policies}: {policies: Policy[]}) {
43+
const sortedPolicies = useMemo(
44+
() => policies.sort((a, b) => a.name.localeCompare(b.name)),
45+
[policies],
46+
);
47+
48+
return <FlatList data={sortedPolicies} renderItem={renderItem} />;
49+
}
50+
```
51+
52+
#### Incorrect (React.memo)
53+
54+
```tsx
55+
const Avatar = React.memo(function Avatar({source, size}: AvatarProps) {
56+
return <Image source={source} style={getAvatarStyle(size)} />;
57+
});
58+
```
59+
60+
### Correct
61+
62+
#### Correct (plain function — compiler memoizes automatically)
63+
64+
```tsx
65+
function ReportScreen({reportID}: {reportID: string}) {
66+
const handlePress = () => {
67+
Navigation.navigate(ROUTES.REPORT_DETAILS.getRoute(reportID));
68+
};
69+
70+
return <Button onPress={handlePress} />;
71+
}
72+
```
73+
74+
#### Correct (plain expression — compiler memoizes automatically)
75+
76+
```tsx
77+
function PolicyList({policies}: {policies: Policy[]}) {
78+
const sortedPolicies = policies.sort((a, b) => a.name.localeCompare(b.name));
79+
80+
return <FlatList data={sortedPolicies} renderItem={renderItem} />;
81+
}
82+
```
83+
84+
#### Correct (plain component — compiler memoizes automatically)
85+
86+
```tsx
87+
function Avatar({source, size}: AvatarProps) {
88+
return <Image source={source} style={getAvatarStyle(size)} />;
89+
}
90+
```
91+
92+
---
93+
94+
### Review Metadata
95+
96+
#### Verification
97+
98+
Before flagging, verify that the file actually compiles with React Compiler:
99+
100+
```bash
101+
npx react-compiler-healthcheck --src "<filepath>" --verbose
102+
```
103+
104+
If the output contains **"Failed to compile"** for the file under review, the rule **does not apply** — the author may have no alternative to manual memoization until the compilation issue is resolved.
105+
106+
#### Condition
107+
108+
The verification step above is a prerequisite. Only flag when the file compiles successfully AND any of these are true in new or modified code:
109+
110+
1. **`useCallback`** — A function is wrapped in `useCallback`. The compiler automatically memoizes closures based on their captured variables.
111+
2. **`useMemo`** — A value is wrapped in `useMemo`. The compiler automatically caches derived values.
112+
3. **`React.memo`** — A component is wrapped in `React.memo` (or `memo` imported from React). The compiler automatically skips re-rendering components whose props haven't changed.
113+
114+
**Response:** Challenge the author: "React Compiler is enabled — remove the manual memoization and restructure the code so the compiler can handle it."
115+
116+
The goal is to fix the root cause (make code compiler-friendly) rather than slap on manual memoization as a workaround.
117+
118+
**Search Patterns:**
119+
- `useCallback\s*\(` — manual callback memoization
120+
- `useMemo\s*\(` — manual value memoization
121+
- `React\.memo\s*\(` or `memo\s*\(` — manual component memoization
122+
- Import statements: `useCallback`, `useMemo` from `react`
123+
124+
**DO NOT flag if:**
125+
- The file does not compile with React Compiler (verified by the compliance check in the Verification section above)
126+
- The code is inside `node_modules/`, `patches/`, or test fixtures
127+
- The manual memoization exists in unchanged lines (pre-existing code not touched by the diff)

.github/ISSUE_TEMPLATE/OnboardOffboardExpertContributor.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,6 @@ Which action do you wish to take for this team member (select one):
3434
### Ring0 Tasks
3535

3636
- [ ] If adding, add to the appropriate GitHub child team of [external-expert-contributors](https://github.com/orgs/Expensify/teams/external-expert-contributors/teams) (each agency must have its own child team)
37+
- [ ] If adding, create an [IdentityDot account](https://stackoverflowteams.com/c/expensify/questions/22914) for the contributor
3738
- [ ] If removing, remove from our organization [here](https://github.com/orgs/Expensify/people)
39+
- [ ] If removing, delete the contributor's [IdentityDot account](https://stackoverflowteams.com/c/expensify/questions/22914)

.github/actions/javascript/getPullRequestIncrementalChanges/getPullRequestIncrementalChanges.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as core from '@actions/core';
22
import {context} from '@actions/github';
3+
import {RequestError} from '@octokit/request-error';
34
import type {PullRequestEvent, PullRequestSynchronizeEvent} from '@octokit/webhooks-types';
45
import {getJSONInput} from '@github/libs/ActionUtils';
56
import CONST from '@github/libs/CONST';
@@ -89,7 +90,29 @@ async function run(): Promise<void> {
8990

9091
// Now we know there are local changes - get PR diff from the GitHub API to compare
9192
console.log(`🌐 Using GitHub API to validate ${localChangedFiles.size} files with local changes`);
92-
const prDiff = Git.parseDiff(await GitHubUtils.getPullRequestDiff(prNumber));
93+
94+
let prDiffString: string;
95+
try {
96+
prDiffString = await GitHubUtils.getPullRequestDiff(prNumber);
97+
} catch (error) {
98+
const isTooLarge =
99+
error instanceof RequestError &&
100+
typeof error.response?.data === 'object' &&
101+
error.response.data !== null &&
102+
'errors' in error.response.data &&
103+
((error.response.data as {errors?: Array<{code?: string}>}).errors ?? []).some((e) => e.code === 'too_large');
104+
105+
if (!isTooLarge) {
106+
throw error;
107+
}
108+
109+
core.warning(`PR #${prNumber} diff is too large for the GitHub API. Skipping incremental change detection.`);
110+
core.setOutput('CHANGED_FILES', JSON.stringify([]));
111+
core.setOutput('HAS_CHANGES', false);
112+
return;
113+
}
114+
115+
const prDiff = Git.parseDiff(prDiffString);
93116

94117
// Compare the local push diff with the PR diff and collect changed files, checking for overlapping content changes at the line level
95118
for (const prFileDiff of prDiff.files) {

.github/actions/javascript/getPullRequestIncrementalChanges/index.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11582,6 +11582,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
1158211582
Object.defineProperty(exports, "__esModule", ({ value: true }));
1158311583
const core = __importStar(__nccwpck_require__(2186));
1158411584
const github_1 = __nccwpck_require__(5438);
11585+
const request_error_1 = __nccwpck_require__(537);
1158511586
const ActionUtils_1 = __nccwpck_require__(6981);
1158611587
const CONST_1 = __importDefault(__nccwpck_require__(9873));
1158711588
const GithubUtils_1 = __importDefault(__nccwpck_require__(9296));
@@ -11655,7 +11656,25 @@ async function run() {
1165511656
}
1165611657
// Now we know there are local changes - get PR diff from the GitHub API to compare
1165711658
console.log(`🌐 Using GitHub API to validate ${localChangedFiles.size} files with local changes`);
11658-
const prDiff = Git_1.default.parseDiff(await GithubUtils_1.default.getPullRequestDiff(prNumber));
11659+
let prDiffString;
11660+
try {
11661+
prDiffString = await GithubUtils_1.default.getPullRequestDiff(prNumber);
11662+
}
11663+
catch (error) {
11664+
const isTooLarge = error instanceof request_error_1.RequestError &&
11665+
typeof error.response?.data === 'object' &&
11666+
error.response.data !== null &&
11667+
'errors' in error.response.data &&
11668+
(error.response.data.errors ?? []).some((e) => e.code === 'too_large');
11669+
if (!isTooLarge) {
11670+
throw error;
11671+
}
11672+
core.warning(`PR #${prNumber} diff is too large for the GitHub API. Skipping incremental change detection.`);
11673+
core.setOutput('CHANGED_FILES', JSON.stringify([]));
11674+
core.setOutput('HAS_CHANGES', false);
11675+
return;
11676+
}
11677+
const prDiff = Git_1.default.parseDiff(prDiffString);
1165911678
// Compare the local push diff with the PR diff and collect changed files, checking for overlapping content changes at the line level
1166011679
for (const prFileDiff of prDiff.files) {
1166111680
const filePath = prFileDiff.filePath;

.github/scripts/createDocsRoutes.ts

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type Section = {
1212
href: string;
1313
title: string;
1414
articles?: Article[];
15+
sections?: Section[];
1516
};
1617

1718
type Hub = {
@@ -61,6 +62,7 @@ function toTitleCase(str: string): string {
6162

6263
/**
6364
* @param filename - The name of the file
65+
* @param order - Optional order from front matter
6466
*/
6567
function getArticleObj(filename: string, order?: number): Article {
6668
const href = filename.replace('.md', '');
@@ -94,46 +96,87 @@ function pushOrCreateEntry<TKey extends HubEntriesKey>(hubs: Hub[], hub: string,
9496
}
9597

9698
function getOrderFromArticleFrontMatter(path: string): number | undefined {
97-
const frontmatter = fs.readFileSync(path, 'utf8').split('---').at(1);
98-
if (!frontmatter) {
99-
return;
99+
try {
100+
const frontmatter = fs.readFileSync(path, 'utf8').split('---').at(1);
101+
if (!frontmatter) {
102+
return undefined;
103+
}
104+
const frontmatterObject = yaml.load(frontmatter) as Record<string, unknown>;
105+
return frontmatterObject.order as number | undefined;
106+
} catch {
107+
return undefined;
108+
}
109+
}
110+
111+
/**
112+
* Build a section from a directory path, with optional parent path for nested href
113+
*/
114+
function buildSection(platformName: string, hub: string, sectionPath: string, parentHref: string): Section {
115+
const sectionName = sectionPath.split('/').pop() ?? sectionPath;
116+
const fullPath = `${docsDir}/articles/${platformName}/${hub}/${sectionPath}`;
117+
const articles: Article[] = [];
118+
const childSections: Section[] = [];
119+
const href = parentHref ? `${parentHref}/${sectionName}` : sectionName;
120+
121+
for (const entry of fs.readdirSync(fullPath)) {
122+
const entryPath = `${fullPath}/${entry}`;
123+
if (entry.endsWith('.md')) {
124+
const order = getOrderFromArticleFrontMatter(entryPath);
125+
articles.push(getArticleObj(entry, order));
126+
} else if (fs.statSync(entryPath).isDirectory()) {
127+
childSections.push(buildSection(platformName, hub, `${sectionPath}/${entry}`, href));
128+
}
129+
}
130+
131+
const section: Section = {
132+
href,
133+
title: toTitleCase(sectionName.replaceAll('-', ' ')),
134+
...(articles.length > 0 && {articles}),
135+
...(childSections.length > 0 && {sections: childSections}),
136+
};
137+
return section;
138+
}
139+
140+
/**
141+
* Flatten sections for lookup by full path (e.g. netsuite/troubleshooting/connection-errors)
142+
*/
143+
function flattenSections(sections: Section[]): Section[] {
144+
const result: Section[] = [];
145+
for (const s of sections) {
146+
result.push(s);
147+
if (s.sections?.length) {
148+
result.push(...flattenSections(s.sections));
149+
}
100150
}
101-
const frontmatterObject = yaml.load(frontmatter) as Record<string, unknown>;
102-
return frontmatterObject.order as number | undefined;
151+
return result;
103152
}
104153

105154
/**
106155
* Add articles and sections to hubs
107156
* @param hubs - The hubs inside docs/articles/ for a platform
108157
* @param platformName - Expensify Classic or New Expensify
109-
* @param routeHubs - The hubs insude docs/data/_routes.yml for a platform
158+
* @param routeHubs - The hubs inside docs/data/_routes.yml for a platform
110159
*/
111160
function createHubsWithArticles(hubs: string[], platformName: ValueOf<typeof platformNames>, routeHubs: Hub[]) {
112161
for (const hub of hubs) {
113-
// Iterate through each directory in articles
114-
for (const fileOrFolder of fs.readdirSync(`${docsDir}/articles/${platformName}/${hub}`)) {
115-
// If the directory content is a markdown file, then it is an article
162+
const basePath = `${docsDir}/articles/${platformName}/${hub}`;
163+
164+
for (const fileOrFolder of fs.readdirSync(basePath)) {
116165
if (fileOrFolder.endsWith('.md')) {
117166
const articleObj = getArticleObj(fileOrFolder);
118167
pushOrCreateEntry(routeHubs, hub, 'articles', articleObj);
119168
continue;
120169
}
121170

122-
// For readability, we will use the term section to refer to subfolders
123-
const section = fileOrFolder;
124-
const articles: Article[] = [];
125-
126-
// Each subfolder will be a section containing articles
127-
for (const subArticle of fs.readdirSync(`${docsDir}/articles/${platformName}/${hub}/${section}`)) {
128-
const order = getOrderFromArticleFrontMatter(`${docsDir}/articles/${platformName}/${hub}/${section}/${subArticle}`);
129-
articles.push(getArticleObj(subArticle, order));
130-
}
171+
const sectionPath = fileOrFolder;
172+
const section = buildSection(platformName, hub, sectionPath, '');
173+
pushOrCreateEntry(routeHubs, hub, 'sections', section);
174+
}
131175

132-
pushOrCreateEntry(routeHubs, hub, 'sections', {
133-
href: section,
134-
title: toTitleCase(section.replaceAll('-', ' ')),
135-
articles,
136-
});
176+
// Add flat section list for nested section page lookup
177+
const hubObj = routeHubs.find((obj) => obj.href === hub);
178+
if (hubObj?.sections?.length) {
179+
(hubObj as Hub & {flatSections?: Section[]}).flatSections = flattenSections(hubObj.sections);
137180
}
138181
}
139182
}

.github/workflows/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ name: CI
88
on: pull_request
99
jobs:
1010
validate:
11-
runs-on: ubuntu-latest
11+
runs-on: blacksmith-2vcpu-ubuntu-2404
1212
steps:
1313
- id: myTrueAction
1414
uses: Expensify/my-action-outputs-true@main

.github/workflows/buildAndroid.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ jobs:
183183
comment-bot: false
184184
rock-build-extra-params: ${{ inputs.variant == 'Adhoc' && '--extra-params "-PreactNativeArchitectures=arm64-v8a,x86_64 --profile"' || '--extra-params "--profile"' }}
185185
custom-identifier: ${{ steps.computeIdentifier.outputs.IDENTIFIER }}
186-
validate-elf-alignment: true
186+
validate-elf-alignment: ${{ inputs.variant != 'Release' && 'true' || 'false' }}
187187

188188
- name: Upload Gradle profile report
189189
if: always()
@@ -283,6 +283,10 @@ jobs:
283283
--key-pass=pass:${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEY_PASSWORD }}
284284
unzip -p Expensify.apks universal.apk > Expensify.apk
285285
286+
- name: Validate ELF alignment for Release APK
287+
if: ${{ inputs.variant == 'Release' && steps.collectArtifacts.outputs.HAS_AAB == 'true' }}
288+
run: npx rock validate-elf-alignment "Expensify.apk"
289+
286290
- name: Upload Android APK build artifact
287291
if: steps.collectArtifacts.outputs.HAS_AAB == 'true'
288292
# v6

0 commit comments

Comments
 (0)