Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1e4c049
feat(lint): disallow redundant React memoization
roryabraham Oct 20, 2026
ba699a0
refactor(hooks): rely on React Compiler caches
roryabraham Oct 20, 2026
d7a3f54
refactor(components): remove manual memoization
roryabraham Oct 20, 2026
6ad3bf6
refactor(pages): remove manual memoization
roryabraham Oct 20, 2026
2e13d85
refactor(workspaces): remove manual memoization
roryabraham Oct 20, 2026
5faf855
refactor: finish React memoization removal
roryabraham Oct 20, 2026
4fcfe0f
test(lint): verify real compiler memoization gates
roryabraham Oct 20, 2026
7e2c855
fix(navigation): isolate lazy placeholder hooks
roryabraham Oct 20, 2026
7e91f33
Merge remote-tracking branch 'origin/main' into rory/disallow-manual-…
roryabraham Oct 21, 2026
b96763f
fix(assets): stop lazy-loading render loops
roryabraham Oct 21, 2026
53b9804
chore: merge main and resolve conflicts
roryabraham Oct 21, 2026
ab88576
Merge remote-tracking branch 'origin/main' into rory/disallow-manual-…
roryabraham Oct 24, 2026
edc3fc9
fix(camera): keep useFocusEffect callback memoized
roryabraham Oct 24, 2026
cd1a4f6
chore: merge main into memoization cleanup
roryabraham Oct 26, 2026
0eb5200
fix(ci): stabilize bundled action module paths
roryabraham Oct 26, 2026
10e49e9
fix(ci): rebuild actions with locked phone library
roryabraham Oct 26, 2026
cdb668b
fix: preserve export dropdown selection
roryabraham Oct 26, 2026
2d15d27
chore: merge main into memoization cleanup
roryabraham Oct 26, 2026
ef88e4e
chore: merge main into memoization cleanup
roryabraham Oct 26, 2026
635151e
fix(types): declare timezone environment variable
roryabraham Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 0 additions & 1 deletion .claude/skills/app-coding-standards/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ Coding standards for the Expensify App. Each standard is a standalone file in `r
- [CONSISTENCY-20](rules/consistency-20-function-names.md) — Name functions with a verb that says what they do

### Clean React Patterns
- [CLEAN-REACT-PATTERNS-0](rules/clean-react-0-compiler.md) — React Compiler compliance
- [CLEAN-REACT-PATTERNS-1](rules/clean-react-1-composition-over-config.md) — Composition over configuration
- [CLEAN-REACT-PATTERNS-2](rules/clean-react-2-own-behavior.md) — Components own their behavior
- [CLEAN-REACT-PATTERNS-3](rules/clean-react-3-context-free-contracts.md) — Context-free component contracts
Expand Down
129 changes: 0 additions & 129 deletions .claude/skills/app-coding-standards/rules/clean-react-0-compiler.md

This file was deleted.

2 changes: 2 additions & 0 deletions .github/scripts/buildActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ async function buildAction(actionPath: string): Promise<boolean> {
entryPoints: [actionPath],
outfile,
bundle: true,
// Shared worktree dependencies must use the same module paths as a clean CI install.
preserveSymlinks: true,
platform: 'node',
target: 'node24',
format: 'esm',
Expand Down
2 changes: 2 additions & 0 deletions config/eslint/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,8 @@ const config = defineConfig([
{
files: ['src/**/*.ts', 'src/**/*.tsx'],
rules: {
// The lint pipeline keeps this diagnostic only when both compilers memoize the file.
'rulesdir/no-manual-memoization': 'error',
'rulesdir/prefer-locale-compare-from-context': 'error',
'rulesdir/no-object-keys-includes': 'error',
},
Expand Down
2 changes: 1 addition & 1 deletion contributingGuides/LINTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,6 @@ The env-var names and TSV format are unchanged from the previous `eslint-seatbel

- [Ratcheting errors](https://www.notion.com/blog/how-we-evolved-our-code-notions-ratcheting-system-using-custom-eslint-rules).
- [`CONSISTENCY-5`: Justify ESLint rule disables](../.claude/skills/app-coding-standards/rules/consistency-5-justify-eslint-disable.md) — when `eslint-disable` is acceptable, and how to document it.
- [`CLEAN-REACT-PATTERNS-0`: React Compiler compliance](../.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md) — why some hook-related rules are suppressed per-file.
- `rulesdir/no-manual-memoization` forbids `React.memo`, `useMemo`, and `useCallback` when both React Compilers memoize the file. Callbacks passed to `useFocusEffect` may retain `useCallback`, which React Navigation requires.
- [`STYLE.md`](./STYLE.md) — the coding style rules many of our ESLint rules enforce.
- [`REACT_COMPILER.md`](./REACT_COMPILER.md) — separate compiler-compliance CI check that runs alongside lint.
8 changes: 7 additions & 1 deletion contributingGuides/REACT_COMPILER.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

React Compiler is enabled in the web build pipeline via `oxc-transform-react` (the Rust React Compiler) and in the Metro (mobile) build pipeline via `babel-plugin-react-compiler`. Because these are two different implementations, they don't always memoize a file the same way: a file can be auto-memoized on native but not on web, or vice versa. To catch that, both the CI check and the ESLint processor run BOTH compilers, via the shared helpers in `config/reactCompiler/` (`checkWithBabel.mjs`, `checkWithOxc.mjs`, and `checkBoth.mjs`).

## Manual memoization

`rulesdir/no-manual-memoization` disallows `React.memo`, `useMemo`, and `useCallback` in files fully compiled and memoized by both compilers. Derive values directly in render and use plain functions instead of maintaining dependency arrays. The lint pipeline allows manual memoization when either compiler cannot memoize the file.

Callbacks passed to React Navigation's `useFocusEffect` are the exception: keep their `useCallback` wrapper because React Navigation requires a stable callback.

## React Compiler CI check

A CI check runs on every PR that modifies `.ts` or `.tsx` files. For each changed file it runs both `babel-plugin-react-compiler` and `oxc-transform-react` and reports, for each, whether the file compiles and whether it is actually memoized.
Expand Down Expand Up @@ -86,7 +92,7 @@ If you added a modification to `SharedValue`, you'll likely encounter this error

### Existing manual memoization could not be preserved. [...]

These types of errors usually occur when the calls to `useMemo` that were made manually are too complex for react-compiler to understand. React compiler is still experimental so unfortunately this can happen.
Remove the manual memoization and derive the value directly in render. This lets React Compiler infer dependencies and choose its own memoization boundaries. The examples below explain the historical errors in files that still need manual memoization because they do not compile on both platforms.

Some specific cases of this error are described below.

Expand Down
80 changes: 80 additions & 0 deletions eslint-plugin-local-rules/no-manual-memoization.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const name = 'no-manual-memoization';

const REACT_NAVIGATION_MODULES = ['@react-navigation/native', '@react-navigation/core'];

const meta = {
type: 'problem',
docs: {
description: 'Disallow manual memoization when both React Compilers memoize the file.',
recommended: 'error',
},
schema: [],
messages: {
redundant: 'React Compiler memoizes this file. Remove {{method}} and derive values directly in render instead. useCallback is only allowed for useFocusEffect callbacks.',
},
};

/** @param {import('eslint').Rule.RuleContext} context */
function create(context) {
const {sourceCode} = context;

function findVariable(node) {
let scope = sourceCode.getScope(node);
while (scope) {
const variable = scope.set.get(node.name);
if (variable) {
return variable;
}
scope = scope.upper;
}
return undefined;
}

function importedMethod(node, moduleName) {
if (node.type === 'Identifier') {
const definition = findVariable(node)?.defs[0];
if (definition?.type !== 'ImportBinding' || definition.parent.source.value !== moduleName || definition.node.type !== 'ImportSpecifier') {
return undefined;
}
return definition.node.imported.name ?? definition.node.imported.value;
}
if (node.type !== 'MemberExpression' || node.object.type !== 'Identifier') {
return undefined;
}
const definition = findVariable(node.object)?.defs[0];
if (definition?.type !== 'ImportBinding' || definition.parent.source.value !== moduleName || definition.node.type === 'ImportSpecifier') {
return undefined;
}
return node.computed ? node.property.value : node.property.name;
}

function isFocusEffectArgument(node) {
return (
node.parent.type === 'CallExpression' &&
node.parent.arguments[0] === node &&
REACT_NAVIGATION_MODULES.some((moduleName) => importedMethod(node.parent.callee, moduleName) === 'useFocusEffect')
);
}

function isFocusCallback(node) {
if (isFocusEffectArgument(node)) {
return true;
}
if (node.parent.type !== 'VariableDeclarator' || node.parent.id.type !== 'Identifier') {
return false;
}
return sourceCode.getDeclaredVariables(node.parent).some((variable) => variable.references.some((reference) => reference.isRead() && isFocusEffectArgument(reference.identifier)));
}

return {
CallExpression(node) {
const method = importedMethod(node.callee, 'react');
if (!['memo', 'useMemo', 'useCallback'].includes(method) || (method === 'useCallback' && isFocusCallback(node))) {
return;
}
context.report({node, messageId: 'redundant', data: {method}});
},
};
}

export {name, meta, create};
12 changes: 8 additions & 4 deletions scripts/lint/processors/ReactCompilerFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import WorkerPool from '../../utils/WorkerPool';
import Processor from '../Processor';

const RULES_SUPPRESSED_BY_REACT_COMPILER = new Set(['react/jsx-no-constructed-context-values', 'rulesdir/no-inline-useOnyx-selector']);
const MANUAL_MEMOIZATION_RULE = 'rulesdir/no-manual-memoization';
const EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN = /\buseCallback\(\) Hook\b|\buseMemo\(\) Hook\b/;
const CACHE_DIR = 'node_modules/.cache/react-compiler';
const REACT_COMPILER_FINGERPRINT_FILES = [
Expand Down Expand Up @@ -126,8 +127,8 @@ async function checkCandidatesWithPool(candidates: Candidate[], checkBoth: Compi
}

/**
* Drop suppressible React-Compiler-redundant messages, but only after both
* compilers memoize the file. Files with no suppressible message are skipped
* Drop redundant messages and enforce no-manual-memoization only after both
* compilers memoize the file. Files with no compiler-dependent message are skipped
* entirely (~60× fewer compiler invocations than the ESLint processor).
*
* Cache is one file per compiler fingerprint and content hash so concurrent
Expand Down Expand Up @@ -166,13 +167,13 @@ async function filterReactCompilerMessages(
if (shouldSkipCompiler(filename)) {
continue;
}
if (fileMessages.some(isSuppressibleMessage)) {
if (fileMessages.some((message) => isSuppressibleMessage(message) || message.ruleID === MANUAL_MEMOIZATION_RULE)) {
candidateNames.push(filename);
}
}

if (candidateNames.length === 0) {
return messages;
return messages.filter((message) => message.ruleID !== MANUAL_MEMOIZATION_RULE);
}

const uncached: Candidate[] = [];
Expand Down Expand Up @@ -220,6 +221,9 @@ async function filterReactCompilerMessages(
);

return messages.filter((message) => {
if (message.ruleID === MANUAL_MEMOIZATION_RULE) {
return memoized.get(message.filePath) === true;
}
if (!memoized.get(message.filePath)) {
return true;
}
Expand Down
6 changes: 3 additions & 3 deletions src/DeepLinkHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {NativeEventSubscription} from 'react-native';

import {useCallback, useEffect, useRef} from 'react';
import {useEffect, useRef} from 'react';
import {Linking} from 'react-native';

import CONST from './CONST';
Expand Down Expand Up @@ -61,14 +61,14 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) {

// An anonymous deep link into a public room needs to be re-fetched after OpenApp settles (see the effect
// below). Track the pending reportID so both the initial-URL and the url-change paths stay in sync.
const trackPendingPublicRoomFromDeepLink = useCallback((url: string, isCurrentlyAuthenticated: boolean) => {
const trackPendingPublicRoomFromDeepLink = (url: string, isCurrentlyAuthenticated: boolean) => {
const deepLinkReportID = getReportIDFromLink(url);
if (!deepLinkReportID || isCurrentlyAuthenticated) {
return;
}
pendingPublicRoomReportID.current = deepLinkReportID;
hasRefetchedPublicRoom.current = false;
}, []);
};

useEffect(() => {
if (
Expand Down
Loading
Loading