Skip to content

Commit 400e0ea

Browse files
committed
fix: conflicts
2 parents a99606f + 2e84417 commit 400e0ea

1,348 files changed

Lines changed: 75386 additions & 11556 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/scripts/createDocsRoutes.ts

Lines changed: 68 additions & 58 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 = {
@@ -60,16 +61,14 @@ function toTitleCase(str: string): string {
6061
}
6162

6263
/**
63-
* @param filename - The name of the file (path used for href)
64+
* @param filename - The name of the file
6465
* @param order - Optional order from front matter
65-
* @param titleOverride - Optional display title (e.g. subfolder name: "Export-Errors" -> "Export Errors")
6666
*/
67-
function getArticleObj(filename: string, order?: number, titleOverride?: string): Article {
67+
function getArticleObj(filename: string, order?: number): Article {
6868
const href = filename.replace('.md', '');
69-
const title = titleOverride ? toTitleCase(titleOverride.replaceAll('-', ' ')) : toTitleCase(href.replaceAll('-', ' '));
7069
return {
7170
href,
72-
title,
71+
title: toTitleCase(href.replaceAll('-', ' ')),
7372
order,
7473
};
7574
}
@@ -97,76 +96,87 @@ function pushOrCreateEntry<TKey extends HubEntriesKey>(hubs: Hub[], hub: string,
9796
}
9897

9998
function getOrderFromArticleFrontMatter(path: string): number | undefined {
100-
const frontmatter = fs.readFileSync(path, 'utf8').split('---').at(1);
101-
if (!frontmatter) {
102-
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;
103108
}
104-
const frontmatterObject = yaml.load(frontmatter) as Record<string, unknown>;
105-
return frontmatterObject.order as number | undefined;
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+
}
150+
}
151+
return result;
106152
}
107153

108154
/**
109155
* Add articles and sections to hubs
110156
* @param hubs - The hubs inside docs/articles/ for a platform
111157
* @param platformName - Expensify Classic or New Expensify
112-
* @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
113159
*/
114160
function createHubsWithArticles(hubs: string[], platformName: ValueOf<typeof platformNames>, routeHubs: Hub[]) {
115161
for (const hub of hubs) {
116-
// Iterate through each directory in articles
117-
for (const fileOrFolder of fs.readdirSync(`${docsDir}/articles/${platformName}/${hub}`)) {
118-
// 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)) {
119165
if (fileOrFolder.endsWith('.md')) {
120166
const articleObj = getArticleObj(fileOrFolder);
121167
pushOrCreateEntry(routeHubs, hub, 'articles', articleObj);
122168
continue;
123169
}
124170

125-
// For readability, we will use the term section to refer to subfolders
126-
const section = fileOrFolder;
127-
const articles: Article[] = [];
128-
129-
// Section can contain .md files directly and/or subfolders (and nested subfolders) that contain .md files
130-
const sectionPath = `${docsDir}/articles/${platformName}/${hub}/${section}`;
131-
for (const entry of fs.readdirSync(sectionPath)) {
132-
const entryPath = `${sectionPath}/${entry}`;
133-
if (entry.endsWith('.md') && fs.statSync(entryPath).isFile()) {
134-
const order = getOrderFromArticleFrontMatter(entryPath);
135-
articles.push(getArticleObj(entry, order));
136-
continue;
137-
}
138-
if (fs.statSync(entryPath).isDirectory()) {
139-
// One level: section/SubFolder/file.md -> href "SubFolder/file", display title = "Troubleshoot SubFolder"
140-
for (const file of fs.readdirSync(entryPath)) {
141-
const filePath = `${entryPath}/${file}`;
142-
if (file.endsWith('.md') && fs.statSync(filePath).isFile()) {
143-
const order = getOrderFromArticleFrontMatter(filePath);
144-
articles.push(getArticleObj(`${entry}/${file}`, order, `Troubleshoot ${entry}`));
145-
continue;
146-
}
147-
if (fs.statSync(filePath).isDirectory()) {
148-
// Two levels: section/SubFolder/NestedFolder/file.md -> href "SubFolder/NestedFolder/file", display title = "Troubleshoot NestedFolder" (e.g. "Troubleshoot Export Errors")
149-
for (const nestedFile of fs.readdirSync(filePath)) {
150-
if (!nestedFile.endsWith('.md')) {
151-
continue;
152-
}
153-
const nestedPath = `${filePath}/${nestedFile}`;
154-
if (!fs.statSync(nestedPath).isFile()) {
155-
continue;
156-
}
157-
const order = getOrderFromArticleFrontMatter(nestedPath);
158-
articles.push(getArticleObj(`${entry}/${file}/${nestedFile}`, order, `Troubleshoot ${file}`));
159-
}
160-
}
161-
}
162-
}
163-
}
171+
const sectionPath = fileOrFolder;
172+
const section = buildSection(platformName, hub, sectionPath, '');
173+
pushOrCreateEntry(routeHubs, hub, 'sections', section);
174+
}
164175

165-
pushOrCreateEntry(routeHubs, hub, 'sections', {
166-
href: section,
167-
title: toTitleCase(section.replaceAll('-', ' ')),
168-
articles,
169-
});
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);
170180
}
171181
}
172182
}

.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/authorChecklist.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,22 @@ on:
99
paths-ignore: ['docs/articles/**/*.md', 'docs/redirects.csv', 'docs/assets/images/**']
1010

1111
jobs:
12+
validate:
13+
uses: ./.github/workflows/contributorValidationGate.yml
14+
with:
15+
PR_NUMBER: ${{ github.event.pull_request.number }}
16+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
17+
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
18+
1219
# Note: PHP specifically looks for the name of this job, "checklist", so if the name of the job is changed,
1320
# then you also need to go into PHP and update the name of this job in the GH_JOB_NAME_CHECKLIST constant
1421
checklist:
22+
needs: [validate]
1523
runs-on: blacksmith-2vcpu-ubuntu-2404
16-
if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]'
24+
if: |
25+
needs.validate.outputs.IS_AUTHORIZED == 'true'
26+
&& github.actor != 'OSBotify'
27+
&& github.actor != 'imgbot[bot]'
1728
steps:
1829
- name: Checkout
1930
# v4

.github/workflows/buildIOS.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,12 @@ jobs:
144144
op read "op://${{ vars.OP_VAULT }}/OldApp_AppStore/${{ vars.APPLE_STORE_PROVISIONING_PROFILE_FILE }}" --force --out-file ./${{ vars.APPLE_STORE_PROVISIONING_PROFILE_FILE }}
145145
op read "op://${{ vars.OP_VAULT }}/OldApp_AppStore_Share_Extension/${{ vars.APPLE_SHARE_PROVISIONING_PROFILE_FILE }}" --force --out-file ./${{ vars.APPLE_SHARE_PROVISIONING_PROFILE_FILE }}
146146
op read "op://${{ vars.OP_VAULT }}/OldApp_AppStore_Notification_Service/${{ vars.APPLE_NOTIFICATION_PROVISIONING_PROFILE_FILE }}" --force --out-file ./${{ vars.APPLE_NOTIFICATION_PROVISIONING_PROFILE_FILE }}
147+
op read "op://${{ vars.OP_VAULT }}/OldApp_AppStore_Live_Activity_Extension/${{ vars.APPLE_LIVE_ACTIVITY_PROVISIONING_PROFILE_FILE }}" --force --out-file ./${{ vars.APPLE_LIVE_ACTIVITY_PROVISIONING_PROFILE_FILE }}
147148
else
148149
op read "op://${{ vars.OP_VAULT }}/OldApp_AdHoc/OldApp_AdHoc.mobileprovision" --force --out-file ./OldApp_AdHoc.mobileprovision
149150
op read "op://${{ vars.OP_VAULT }}/OldApp_AdHoc_Share_Extension/OldApp_AdHoc_Share_Extension.mobileprovision" --force --out-file ./OldApp_AdHoc_Share_Extension.mobileprovision
150151
op read "op://${{ vars.OP_VAULT }}/OldApp_AdHoc_Notification_Service/OldApp_AdHoc_Notification_Service.mobileprovision" --force --out-file ./OldApp_AdHoc_Notification_Service.mobileprovision
152+
op read "op://${{ vars.OP_VAULT }}/OldApp_AdHoc_Live_Activity_Extension/OldApp_AdHoc_LiveActivityExtension.mobileprovision" --force --out-file ./OldApp_AdHoc_LiveActivityExtension.mobileprovision
151153
fi
152154
op read "op://${{ vars.OP_VAULT }}/New Expensify Distribution Certificate/Certificates.p12" --force --out-file ./Certificates.p12
153155
@@ -169,6 +171,8 @@ jobs:
169171
<string>${{ vars.APPLE_SHARE_PROVISIONING_PROFILE_NAME }}</string>
170172
<key>${{ vars.APPLE_ID }}.NotificationServiceExtension</key>
171173
<string>${{ vars.APPLE_NOTIFICATION_PROVISIONING_PROFILE_NAME }}</string>
174+
<key>${{ vars.APPLE_ID }}.LiveActivityExtension</key>
175+
<string>${{ vars.APPLE_LIVE_ACTIVITY_PROVISIONING_PROFILE_NAME }}</string>
172176
</dict>
173177
</dict>
174178
</plist>
@@ -189,6 +193,8 @@ jobs:
189193
<string>(OldApp) AdHoc: Share Extension</string>
190194
<key>com.expensify.expensifylite.adhoc.NotificationServiceExtension</key>
191195
<string>(OldApp) AdHoc: Notification Service</string>
196+
<key>com.expensify.expensifylite.adhoc.LiveActivityExtension</key>
197+
<string>(OldApp) AdHoc: LiveActivityExtension</string>
192198
</dict>
193199
</dict>
194200
</plist>
@@ -211,9 +217,9 @@ jobs:
211217
id: prepare-profiles
212218
run: |
213219
if [ "${{ inputs.variant }}" == "Release" ]; then
214-
echo 'PROFILES=[{"name":"${{ vars.APPLE_STORE_PROVISIONING_PROFILE_NAME }}","file":"./${{ vars.APPLE_STORE_PROVISIONING_PROFILE_FILE }}"},{"name":"${{ vars.APPLE_SHARE_PROVISIONING_PROFILE_NAME }}","file":"./${{ vars.APPLE_SHARE_PROVISIONING_PROFILE_FILE }}"},{"name":"${{ vars.APPLE_NOTIFICATION_PROVISIONING_PROFILE_NAME }}","file":"./${{ vars.APPLE_NOTIFICATION_PROVISIONING_PROFILE_FILE }}"}]' >> "$GITHUB_OUTPUT"
220+
echo 'PROFILES=[{"name":"${{ vars.APPLE_STORE_PROVISIONING_PROFILE_NAME }}","file":"./${{ vars.APPLE_STORE_PROVISIONING_PROFILE_FILE }}"},{"name":"${{ vars.APPLE_SHARE_PROVISIONING_PROFILE_NAME }}","file":"./${{ vars.APPLE_SHARE_PROVISIONING_PROFILE_FILE }}"},{"name":"${{ vars.APPLE_NOTIFICATION_PROVISIONING_PROFILE_NAME }}","file":"./${{ vars.APPLE_NOTIFICATION_PROVISIONING_PROFILE_FILE }}"},{"name":"${{ vars.APPLE_LIVE_ACTIVITY_PROVISIONING_PROFILE_NAME }}","file":"./${{ vars.APPLE_LIVE_ACTIVITY_PROVISIONING_PROFILE_FILE }}"}]' >> "$GITHUB_OUTPUT"
215221
else
216-
echo 'PROFILES=[{"name":"(OldApp) AdHoc","file":"./OldApp_AdHoc.mobileprovision"},{"name":"(OldApp) AdHoc: Share Extension","file":"./OldApp_AdHoc_Share_Extension.mobileprovision"},{"name":"(OldApp) AdHoc: Notification Service","file":"./OldApp_AdHoc_Notification_Service.mobileprovision"}]' >> "$GITHUB_OUTPUT"
222+
echo 'PROFILES=[{"name":"(OldApp) AdHoc","file":"./OldApp_AdHoc.mobileprovision"},{"name":"(OldApp) AdHoc: Share Extension","file":"./OldApp_AdHoc_Share_Extension.mobileprovision"},{"name":"(OldApp) AdHoc: Notification Service","file":"./OldApp_AdHoc_Notification_Service.mobileprovision"},{"name":"(OldApp) AdHoc: LiveActivityExtension","file":"./OldApp_AdHoc_LiveActivityExtension.mobileprovision"}]' >> "$GITHUB_OUTPUT"
217223
fi
218224
219225
- name: Rock Remote Build - iOS

.github/workflows/cla.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ on:
88
branches: [main]
99

1010
jobs:
11+
validate:
12+
if: ${{ github.event_name == 'pull_request_target' }}
13+
uses: ./.github/workflows/contributorValidationGate.yml
14+
with:
15+
PR_NUMBER: ${{ github.event.pull_request.number }}
16+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
17+
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
18+
1119
CLA:
20+
needs: [validate]
21+
if: |
22+
always()
23+
&& (github.event_name == 'issue_comment' || needs.validate.outputs.IS_AUTHORIZED == 'true')
1224
uses: Expensify/GitHub-Actions/.github/workflows/cla.yml@main
1325
secrets: inherit

0 commit comments

Comments
 (0)