Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5810840
Add public Onyx.get, its read guards, and the ONYX-1 review rule
LukasMod Sep 24, 2026
9867dba
Cover Onyx.multiGet in no-unsafe-onyx-read, the bypass check and the …
LukasMod Sep 24, 2026
8d584a1
Block eslint-disable over runtime OnyxUtils imports in the bypass check
LukasMod Sep 25, 2026
00cb4a9
Add workflow requesting Onyx reviewers for new Onyx.get and Onyx.mult…
LukasMod Sep 25, 2026
312ccf8
Apply review and update doc
fabioh8010 Sep 29, 2026
4eff1c8
Update react-native-onyx to 3.0.119
LukasMod Oct 7, 2026
33ba092
Rename the Onyx connect bypass check to onyxLintBypass
LukasMod Oct 16, 2026
ecf990b
Split the Search snapshot key check out of no-unsafe-onyx-read into n…
LukasMod Oct 16, 2026
cc5b636
Require a reason to disable no-onyx-get-snapshot-key
LukasMod Oct 16, 2026
b2af92b
Close the ONYX-1 gaps a blind review of Onyx.get misuse probes exposed
LukasMod Oct 16, 2026
f8668d6
Resolve Onyx read aliases by binding and catch same-file helpers that…
LukasMod Oct 19, 2026
3ba0ca2
Match ESLint directive parsing in the Onyx lint bypass checks and dro…
LukasMod Oct 19, 2026
cabb2c9
Report multiGet key findings on the call line so one reasoned directi…
LukasMod Oct 19, 2026
5277811
Cover a destructured alias declared below its use and a cast on the c…
LukasMod Oct 19, 2026
d52f209
Fix the Onyx reading doc links and name the key in the unresolvable k…
LukasMod Oct 19, 2026
7e8e2e3
Merge remote-tracking branch 'origin/main' into feat/onyx-get-async-m…
LukasMod Oct 21, 2026
5410455
Catch eslint config comments that turn off the Onyx lint bans in the …
LukasMod Oct 21, 2026
5b243de
Escalate handlers stored on objects and classes in no-unsafe-onyx-read
LukasMod Oct 21, 2026
e754d3d
Share the Search snapshot key check between useOnyx and its lint rule
LukasMod Oct 21, 2026
423d097
Limit the same-tick read rule to writes that land after a microtask
LukasMod Oct 21, 2026
442486d
Align ONYX-1 section B with rule B1 on which Onyx writes land late
LukasMod Oct 22, 2026
7b4d8ae
Follow casts, callback hook aliases, useMemo factories and by-referen…
LukasMod Oct 22, 2026
7c83781
Fail the Onyx bypass check on seatbelt rows and blanket disables over…
LukasMod Oct 22, 2026
e0fcb02
Require Onyx.get inside a component or hook and follow transitions, A…
LukasMod Oct 22, 2026
3feb156
Key the CI ESLint cache on the local rules and the snapshot rule inputs
LukasMod Oct 22, 2026
c0dcf78
Document the component scope and direct call checks of no-unsafe-onyx…
LukasMod Oct 22, 2026
65001b4
Merge remote-tracking branch 'origin/main' into feat/onyx-get-async-m…
LukasMod Oct 22, 2026
d25df6e
Match ONYX-1 index entry separator to the other rules
LukasMod Oct 22, 2026
d8050cd
Document Onyx.clear as a late write and the no-op set of a mutated re…
LukasMod Oct 23, 2026
b69f95d
Report the Onyx object used as a value outside a member access
LukasMod Oct 23, 2026
17fe7b0
Count a blanket disable over a value use of the Onyx object as a bypass
LukasMod Oct 23, 2026
a4ffbeb
Reword the Onyx lint comments, messages and docs in plain language
LukasMod Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/skills/app-coding-standards/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ Coding standards for the Expensify App. Each standard is a standalone file in `r
- [UI-4](rules/ui-4-layout-spacing-tokens.md) — Type and responsive insets come from tokens
- [UI-5](rules/ui-5-reuse-existing-styles.md) — Reuse an existing style or StyleUtils helper instead of adding a new one

### Onyx
- [ONYX-1](rules/onyx-1-no-render-reachable-onyx-read.md) — Keep Onyx reads off the render path and out of a written tick

## Usage

**During development**: When writing or modifying `src/` files, consult the relevant standard files for detailed conditions, examples, and exceptions.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
---
ruleId: ONYX-1
title: Keep Onyx reads off the render path and out of a written tick
---

## [ONYX-1] Keep Onyx reads off the render path and out of a written tick

### Reasoning

`await Onyx.get()` reads a key once and never subscribes. `Onyx.multiGet()` does the same for each key in an array, so everything below about `Onyx.get` applies to it too. `no-unsafe-onyx-read` catches most misuse, so this rule covers only what lint can't see.

Do not re-check these:

| Enforced | By |
|---|---|
| `Onyx.get` or `Onyx.multiGet` outside `src/components`, `src/pages`, `src/hooks` and `tests` | `no-unsafe-onyx-read` |
| A read written in a component or hook body (including `memo` and `forwardRef` wrappers and any function with a top-level `return <JSX>`), in a JSX expression, in a `useMemo` callback, a `selector`, a lazy initializer or a `useSyncExternalStore` snapshot, in a same-file function passed by name to a `useMemo`, `useState`, `useReducer` or `useSyncExternalStore` slot, to `memo` or `forwardRef`, or to a `selector`, in a same-file helper that render or module scope calls, or at module scope. Not a helper imported from another file, see A | `no-unsafe-onyx-read` |
| A read inside an effect, or in a same-file function an effect calls | `no-unsafe-onyx-read` |
| A read in a module-level function, object or class in `src/components`, `src/pages` or `src/hooks`, outside a component or hook body | `no-unsafe-onyx-read` |
| A reference to `Onyx.get` or `Onyx.multiGet` that is not a direct call (`.call`, `.bind`, `keys.map(Onyx.get)`, `const read = Onyx.get`, `const {get} = Onyx`) | `no-unsafe-onyx-read` |
| The `Onyx` object used as a value instead of through a member, such as `{...Onyx}` or `const api = Onyx` | `no-unsafe-onyx-read` |
| A Search snapshot key, or a key lint can't resolve, including any `multiGet` element | `no-onyx-get-snapshot-key` |
| A runtime import of `react-native-onyx/dist/OnyxUtils` | `@typescript-eslint/no-restricted-imports` |
| An inline `eslint-disable` that covers `no-unsafe-onyx-read`, named or blanket, or one over a runtime OnyxUtils import, in any linted file outside `tests/` | `scripts/checkOnyxLintBypass.ts` |
| An `eslint-disable` of `no-onyx-get-snapshot-key` with a reason under 12 letters or digits after `--` or in block form, in any linted file outside `tests/` | `scripts/checkOnyxLintDisableReason.ts` |
| A missing `await` whose value is then used | `tsc` |

A disable of `no-onyx-get-snapshot-key` is allowed with a reason, and the script checks only that it holds at least 12 letters or digits. Judge its content under E1. The key `${ONYXKEYS.COLLECTION.REPORT}${id}`, inline or through a const, passes `no-onyx-get-snapshot-key` without a disable when `id` is a same-file `const` bound to `ONYXKEYS.CONCIERGE_REPORT_ID` by `await Onyx.get(...)`, `const [id] = useOnyx(...)` with no `selector`, or the matching slot of `Onyx.multiGet(...)`. That read needs no E1 review, and A through D still apply to it.

What lint leaves to review is below: a read reached from render across files (A), write ordering (B), a handler an effect in another file calls (C), a read value shown later as current (D), and the content of a disable reason (E).

Mutating a read result writes the cache, since a single-key read returns the cached object and a collection read returns the cached members. `useOnyx` hands out the same object, so this stays a documented convention and isn't flagged here. A `set` with that same object can be skipped as unchanged, so the mutation is neither saved nor announced.

**A. Position.** A read runs during render when any render body reaches it, whichever file holds it. Lint counts a function as a render body when it's named like a component or hook, has a top-level `return <JSX>`, is wrapped in `memo` or `forwardRef`, or is passed to a `useMemo`, `useState`, `useReducer` or `useSyncExternalStore` slot or a `selector` option, inline or by name. It follows a helper the render body calls in the same file, through aliases and `useCallback`, but misses one imported from another file. It also misses a helper that returns JSX from inside an `if` or `switch`, a component passed inline to any call other than `memo` or `forwardRef` (such as a HOC), and a function passed to a child that calls it during render. When one of these helpers is `async`, `tsc` catches it only where the Promise lands in a slot typed `ReactElement`, such as `renderItem`. A JSX child or a `render*` prop typed `ReactNode` accepts a Promise, so rely on `tsc` only when it reports an error.

**B. Tick.** Awaiting a read doesn't wait for an earlier write. `Onyx.get` captures the cached value when it's called. `set` and `multiSet` update the cache before they return, but `merge`, `mergeCollection`, `setCollection`, `clear` and every `update` operation, including SET, apply to the cache after at least one microtask. A read queued behind them sees the old value, so await the write or read first. A derived key (`ONYXKEYS.DERIVED.*`) is recomputed on a microtask after its source changes, so a read in the same synchronous stretch returns the old derived value, even after a `set` that already updated the source. Read a derived key only before writing its sources.

**C. Effect in another file.** Lint bans a read inside an effect, but only within one file. A handler that reads can still end up in an effect when it's passed to a child or hook that calls it from `useEffect`, `useLayoutEffect` or `useFocusEffect`. A receiver that only registers the handler for an event (an `on*` prop, `addEventListener`, `useKeyboardShortcut`) is fine, even if the handler sits in that effect's dependency array.

**D. Output.** A read value that reaches the screen later, through state, a ref or a module variable a component renders, stays frozen at the moment of the read. Flag it when the screen presents it as the current value.

### Incorrect

**A1. A hook calls a reading helper from render.**

```ts
// src/hooks/useCurrentUserEmail/getCurrentUserEmail.ts
export default async function getCurrentUserEmail() {
return (await Onyx.get(ONYXKEYS.SESSION))?.email; // fine on its own, and lint only sees this file
}

// src/hooks/useCurrentUserEmail/index.ts
function useCurrentUserEmail() {
return getCurrentUserEmail(); // render gets a Promise, and tsc accepts it
}
```

**A2. A render body lint doesn't recognize.**

```tsx
// The JSX return sits inside a switch, so lint sees no top-level JSX return and no render body.
async function renderTagBadge(policyID: string) {
const tags = await Onyx.get(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`);

switch (Object.keys(tags ?? {}).length) {
case 0:
return null;
default:
return <Badge />;
}
}

// A render prop typed ReactNode accepts the Promise, and lint sees only a callback passed as a prop
<Section renderTitle={() => renderTagBadge(policyID)} />;
```

**B. A read right after a write.**

```tsx
const onSave = async () => {
Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true});
const account = await Onyx.get(ONYXKEYS.ACCOUNT); // isLoading is still the old value
};
```

**C. A child calls the handler from an effect.**

```tsx
// src/pages/ContactsPage.tsx
function ContactsPage() {
const importContacts = async () => saveContacts(await Onyx.get(ONYXKEYS.COUNTRY_CODE));
return <ContactsList onReady={importContacts} />;
}

// src/components/ContactsList.tsx
function ContactsList({onReady}: Props) {
useEffect(() => {
onReady(); // the read now runs inside this effect, and lint only checks one file
}, [onReady]);
}
```

**D. A value shown as current, captured at a tap.**

```tsx
function CurrentTheme() {
const [theme, setTheme] = useState<string>();
const onPress = async () => setTheme(await Onyx.get(ONYXKEYS.PREFERRED_THEME));

// Change the theme in Settings and this still shows the old one.
return <Text onPress={onPress}>Current theme: {theme}</Text>;
}
```

### Correct

```tsx
// A: the hook subscribes.
function useCurrentUserEmail() {
const [email] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email});
return email;
}

// B: await the write, or read before it.
const onSave = async () => {
await Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true});
const account = await Onyx.get(ONYXKEYS.ACCOUNT);
};

// B, derived: read the derived key before writing its source.
const onCloseCard = async (cardID: string) => {
const cards = await Onyx.get(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST);
Onyx.merge(ONYXKEYS.CARD_LIST, {[cardID]: {state: CONST.EXPENSIFY_CARD.STATE.CLOSED}});
};

// C: keep the subscription in the parent and pass the value down.
const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE);
return <ContactsList onReady={() => saveContacts(countryCode)} />;

// D: anything shown as current stays on useOnyx.
const [theme] = useOnyx(ONYXKEYS.PREFERRED_THEME);
return <Text>Current theme: {theme}</Text>;
```

---

### Review Metadata

#### A. Position

- A1. The diff adds a read to a function that some caller reaches from render: a component or hook body, a `useMemo` callback, a `useOnyx` selector, a lazy initializer, an IIFE or array callback in the body, or a local function the body calls. Grep `src/` for the function's name, ignoring imports. If a caller is a plain function, repeat the Grep on its name. When the render-side call is in the diff, comment on that call and name the read. Otherwise, comment on the read and name the calling file and line. A3 covers a new call whose value is discarded. A1 covers every other render-reached read.
- A2. The function holding the read returns JSX from any branch, or is passed as `renderItem`, `ListHeaderComponent`, or any `render*` or `*Component` prop. Flag the read.
- A3. The diff adds a call at a render position in a component or hook, the call's value is discarded or the callee returns `void`, and the callee's file contains `Onyx.get` or `Onyx.multiGet`. Comment on the call.
- A4. The diff passes a function that reads, or whose file contains `Onyx.get` or `Onyx.multiGet`, as a prop, and the receiver calls that prop from render. Open the receiver's file and Grep the prop's name followed by `(`. Follow forwarded props. If the receiver can't be resolved (a spread, or a component held in a variable), ask the author to confirm nothing calls it during render.

#### B. Tick

- B1. An `Onyx.merge`, `Onyx.mergeCollection`, `Onyx.setCollection`, `Onyx.update` or `Onyx.clear` that isn't awaited is followed in the same tick by a read of the written key, a member of the written collection, or a `DERIVED` key built from either (see `dependencies` in `src/libs/actions/OnyxDerived/configs/`). `Onyx.set` and `Onyx.multiSet` update the cache before they return, so a read after them is fine, but a `DERIVED` key still lags. The read can be direct or in a called function whose file contains it. Repeat on any plain function it calls. Comment on the write, naming the callee and the key.
- B2. The diff adds a read to a function whose callers write that key before calling it in the same tick.

#### C. Effect in another file

- C1. The diff passes a function that reads, or whose file contains `Onyx.get` or `Onyx.multiGet`, to another component or hook, or turns a function already passed that way into one that reads. Open the receiver, Grep the prop's name followed by `(`, and flag when a call sits inside a `useEffect`, `useLayoutEffect` or `useFocusEffect` callback, directly or through a local function. Follow forwarded props. The prop's name doesn't matter: an `onReady` the receiver calls from an effect is a finding. Comment on the prop, naming the receiver's effect.

#### D. Output

- D1. The read's value goes to a `useState` setter, a `useRef`, or a module variable, a render position in the same file reads it, and the screen presents it as the current value. Comment on the read.

#### E. Reason

- E1. The diff adds or edits an `eslint-disable` of `no-onyx-get-snapshot-key`. The text after `--` must say why that key never went through the `useOnyx` snapshot redirection: it replaces a module-level `Onyx.connect()` used only by handlers, or the value is never read inside a Search scope. Flag a reason that restates the rule, says live data is wanted without saying why the snapshot was never involved, or names a different key than the one read. Comment on the directive.

**DO NOT flag if:**

- The read sits in an event handler or `useCallback` body that render doesn't invoke, the reading function isn't exported, isn't a render body by A2, and isn't passed as a render callback, and no B, C, D or E item applies
- The prop holding the reader is named `on*` or `handle*`, and every receiver attaches it to an event or calls it from a handler. A receiver that calls it from an effect is C1 regardless of the name
- The value only reaches a handler argument or a request field and is never rendered
- The receiver only registers the handler for an event (an `on*` prop, `addEventListener`, `useKeyboardShortcut`), even if the handler is in an effect's dependency array
- The removed `useOnyx` value appears nowhere in the diff except the converted call's arguments
- The value is meant as a point-in-time value taken at the event, and nothing downstream expects it to update
- The write is awaited, or the read runs in its `.then`, and the read key isn't derived from the written one
- (B only) The read sits in a deliberate deferral: a `.then`, a timer, `runAfterTransitions`, `runAfterInteractions`, or a callback passed to an async API. Don't suggest hoisting it above the deferral, since that pins the value to the moment before the wait. A `.then` chained on the read itself is not a deferral, and a deferral never excuses an A, C or D finding
- The write and the read are in exclusive branches, or the write's branch returns first
- The keys differ, and the read key isn't derived from the written one and isn't a member of the written collection

**Search Patterns** (hints for reviewers):

- `Onyx.get(`, `Onyx.multiGet(`
- `Onyx.merge(`, `Onyx.update(`, `Onyx.set(`, `Onyx.mergeCollection(`
- `ONYXKEYS.DERIVED`
- removed `useOnyx(` lines in the diff, then that variable's name in the rest of the diff
- `useEffect(`, `useLayoutEffect(`, `useFocusEffect(`, `useRef(`, `useState(`
- `runAfterTransitions`, `runAfterInteractions`, `.then(`, `setTimeout(` around a read that follows a write
11 changes: 7 additions & 4 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ jobs:
# (espree/acorn) whose bumps can silently change parse results, plus
# typescript/ts-api-utils since a TS bump can change type-aware lint results.
# The cache key hashes this slice so a cold full re-lint happens only when
# the lint config or a lint dependency changes -- not on every dep bump.
# the lint config, a local rule or a lint dependency changes, not on every dep bump.
# The local rules are in the key because ESLint's own cache hashes the serialized
# config, which leaves out rule functions, so editing a rule alone never invalidates it.
# no-onyx-get-snapshot-key also loads SnapshotUtils.ts and runtimeDefaults.ts at lint time.
- name: Extract lint-related deps from package-lock.json
shell: bash
run: |
Expand All @@ -70,9 +73,9 @@ jobs:
uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb
with:
path: node_modules/.cache/eslint
key: ${{ runner.os }}-eslint-${{ hashFiles('eslint.config.mjs', 'config/eslint/**', 'lint-deps-lock.json') }}-${{ github.sha }}
key: ${{ runner.os }}-eslint-${{ hashFiles('eslint.config.mjs', 'config/eslint/**', 'eslint-plugin-local-rules/**', 'src/libs/SnapshotUtils.ts', 'src/CONST/runtimeDefaults.ts', 'lint-deps-lock.json') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-eslint-${{ hashFiles('eslint.config.mjs', 'config/eslint/**', 'lint-deps-lock.json') }}-
${{ runner.os }}-eslint-${{ hashFiles('eslint.config.mjs', 'config/eslint/**', 'eslint-plugin-local-rules/**', 'src/libs/SnapshotUtils.ts', 'src/CONST/runtimeDefaults.ts', 'lint-deps-lock.json') }}-

# ESLint's cache doesn't track cross-file TypeScript dependencies, which can cause stale errors.
# If lint fails, we clear the cache and retry to rule out false positives.
Expand All @@ -96,7 +99,7 @@ jobs:
if: always()
with:
path: node_modules/.cache/eslint
key: ${{ runner.os }}-eslint-${{ hashFiles('eslint.config.mjs', 'config/eslint/**', 'lint-deps-lock.json') }}-${{ github.sha }}
key: ${{ runner.os }}-eslint-${{ hashFiles('eslint.config.mjs', 'config/eslint/**', 'eslint-plugin-local-rules/**', 'src/libs/SnapshotUtils.ts', 'src/CONST/runtimeDefaults.ts', 'lint-deps-lock.json') }}-${{ github.sha }}

# If lint tightened the seatbelt baseline (i.e. fewer baselined errors than last time),
# commit the updated TSV back to `main` as OSBotify. Only runs under `workflow_call`
Expand Down
Loading
Loading