Skip to content

[Onyx bump] Bump react-native-onyx to 3.0.119 - Onyx GET asynchronous - #102135

Open
LukasMod wants to merge 20 commits into
Expensify:mainfrom
callstack-internal:feat/onyx-get-async-mutable
Open

LukasMod wants to merge 20 commits into
Expensify:mainfrom
callstack-internal:feat/onyx-get-async-mutable

Conversation

@LukasMod

@LukasMod LukasMod commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Explanation of Change

  1. Bumps react-native-onyx from 3.0.117 to 3.0.119. The bump contains 2 Onyx PRs:
    • 3.0.118: fix(storage): reopen the IDB connection on UnknownError: Internal error. (fix(storage): reopen the IDB connection on UnknownError: Internal error. react-native-onyx#853). Onyx classified UnknownError: Internal error. as an unclassifiable error, so the operation layer retried each write six times and gave up, while the connection layer never reopened the database. This maps that exact wording to the class the connection layer already owns, so a failed read or write now drops the cached connection, reopens it, and retries (budgeted at 3 attempts, reset on any success). If the reopen does not help, the existing heal budget exhausted alert fires and the write is dropped, instead of every operation burning its full retry budget.
    • 3.0.119: Onyx.get(key) and Onyx.multiGet(keys) become public and typed (Add public Onyx.get - mutable react-native-onyx#850). No app code reads with them yet. The only source change is src/setup/addUtilsToWindow.ts and react-native-onyx.d.ts dropping the hand-made connectWithoutView shim and its Window declaration (16 lines), so the dev console Onyx.get and Onyx.log go through the library method.
  2. Two new lint rules, both error repo-wide. rulesdir/no-unsafe-onyx-read guards where a read may run: only in src/components, src/pages, src/hooks and tests, and never during render, at module scope or inside an effect (a same-file helper that render calls counts as render). A one-shot read in those places returns stale data or re-runs on every render, so there is no valid case and the rule cannot be disabled. rulesdir/no-onyx-get-snapshot-key guards which keys may be read: it bans the Search snapshot keys (SEARCH_SNAPSHOT_ONYX_KEYS) and any key lint cannot resolve, including each multiGet element, because useOnyx redirects those keys to a Search snapshot and Onyx.get would return live data where the component saw the snapshot. It is a separate rule because some reads are known safe (a migrated module-level Onyx.connect, a useOnyxWithoutSnapshots subscription), so it can be disabled with a mandatory reason.
  3. The Concierge report key is exempt: it is a REPORT key, but Search never returns it, so it is always read live. The exemption applies when the ID is a same-file const read from ONYXKEYS.CONCIERGE_REPORT_ID.
  4. Two lint-pipeline scripts enforce the disable policies. scripts/onyxLintBypass.ts (renamed from onyxConnectBypass.ts) fails lint on any inline disable of no-onyx-connect, no-unsafe-onyx-read or the OnyxUtils import restriction. scripts/onyxLintDisableReason.ts accepts a disable of no-onyx-get-snapshot-key only in the -line or -next-line form with at least 12 letters or digits after --.
  5. Review and docs: ONYX-1 in the coding standards skill lists what lint enforces so the reviewer does not re-check it, and covers what lint cannot see (cross-file render reach, effects in another file, reads ordered after writes, derived keys, values parked in state) plus judging the content of a disable reason. ONYX-DATA-MANAGEMENT.md lists Onyx.get and Onyx.multiGet as read paths, names when a live read is right (a migrated module-level Onyx.connect used only by handlers), and shows the disable shape, including where it sits for a multiGet.
  6. .github/workflows/onyxGetReviewers.yml requests the Onyx performance reviewers and leaves a comment whenever a non-draft PR adds a net-new Onyx.get or Onyx.multiGet call under src/, ignoring mocks, comment lines and the dev console's window.Onyx.get. It mirrors onyxConnectWithoutViewReviewers.yml.

Automated coverage: tests/unit/NoUnsafeOnyxReadRuleTest.ts (141 cases), tests/unit/NoOnyxGetSnapshotKeyRuleTest.ts (88, including an exhaustive check that the rule rejects exactly the ONYXKEYS entries useOnyx would redirect), tests/unit/OnyxLintBypassTest.ts (38) and tests/unit/OnyxLintDisableReasonTest.ts (10), all passing.

Fixed Issues

$ #101910
PROPOSAL:

Tests

  • Verify that no errors appear in the JS console

Onyx 3.0.118

The steps below cover Onyx #853 (reopen the IDB connection on UnknownError: Internal error.).

Test 1: Data loads and persists after the IndexedDB database is deleted (web only)

  1. Sign in and confirm the app loads your data normally, with no "Something went wrong" or "we have not been able to load all of your data" message.
  2. In the DevTools console run indexedDB.deleteDatabase('OnyxDB');.
  3. Refresh and sign in again.
  4. Confirm your data loads and is persisted across another refresh.
  5. Change a setting (for example the theme), refresh, and confirm it was saved. Alternatively run (await import('/node_modules/idb-keyval/dist/index.js')).get('test') in the console and confirm it resolves without an error.

Onyx 3.0.119

The steps below cover the public Onyx.get and Onyx.multiGet API, the two lint rules with their scripts, and the reviewers workflow. No extra code is needed.

Test 2: Dev console helpers go through the library method (no other usage currently)

  1. Sign in and open a chat.
  2. Run await Onyx.get('session') in the console and confirm it returns the session object.
  3. Run Onyx.log('session') and confirm the session is logged.
  4. Confirm no errors appear in the console.

Offline tests

QA Steps

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

TEST 1:

onyxget-web-1.mov

TEST 2:

Screenshot 2026-09-24 at 11 08 01
onyxget-web-2.mov

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ This PR is possibly changing native code and/or updating libraries, it may cause problems with HybridApp. Please check if any patch updates are required in the HybridApp repo and run an AdHoc build to verify that HybridApp will not break. Ask Contributor Plus for help if you are not sure how to handle this. ⚠️

@LukasMod
LukasMod force-pushed the feat/onyx-get-async-mutable branch 3 times, most recently from 02c7b44 to f27fb19 Compare September 24, 2026 09:34
Comment thread scripts/onyxConnectBypass.ts Outdated
Comment thread src/setup/addUtilsToWindow.ts
@LukasMod
LukasMod requested a review from tgolen September 25, 2026 08:44
@LukasMod

Copy link
Copy Markdown
Contributor Author

I’ll keep it as a draft until the Onyx change is merged and we can properly bump the Onyx version here

Comment thread .github/workflows/onyxGetReviewers.yml Outdated
@LukasMod
LukasMod force-pushed the feat/onyx-get-async-mutable branch from a8691eb to 4eff1c8 Compare October 16, 2026 12:40
@LukasMod

Copy link
Copy Markdown
Contributor Author

@MelvinBot review

@MelvinBot

Copy link
Copy Markdown
Contributor

Code review

The main risk: both lint rules can be bypassed without any eslint-disable, through ordinary code patterns. I'd fix items 1–4 before merging. Items 5–10 are smaller.

Rule gaps (reads that are not flagged)

  1. Render-time reads through a helper are not flagged. In no-unsafe-onyx-read.js:342, any non-hook, non-component function becomes DEFERRED, which is then treated as EVENT. isReferencedAtRenderTime never checks for a plain call. So these all pass: const read = () => Onyx.get(K); const v = read(); in a component body, const cb = useCallback(() => Onyx.get(K), []); cb();, and a module helper called from render. That is the stale render read the rule exists to stop.
  2. TS-wrapped objects are invisible to both rules. onyxReadUtils.js:263 requires node.object to be a bare Identifier. (Onyx as typeof Onyx).get(snapshotKey) and Onyx!.get(...) are skipped by both rules. onyxLintBypass.ts already unwraps these with unwrapExpression, so the rules should do the same.
  3. Alias tracking depends on source order. Aliases are registered in the VariableDeclarator visitor (onyxReadUtils.js:301). A component that calls read(KEY) above a later module-level const read = Onyx.get; is never recognized. Collect aliases in Program first, or resolve them in Program:exit.

Config robustness

  1. The repo root comes from process.cwd() (onyxReadUtils.js:21). If ESLint runs from outside App/, for example from an editor in a multi-root workspace, REPO_ROOT is null. Then no-onyx-get-snapshot-key throws at load and breaks the whole config, and no-unsafe-onyx-read allows every read because getRepoRelativePath returns null. Resolve the root from __dirname instead.
  2. The lint cache goes stale. no-onyx-get-snapshot-key.js:54 reads SEARCH_SNAPSHOT_ONYX_KEYS from another file at load time. Lint runs with --cache --cache-strategy content, so when a key is added to that list, unchanged files that already read it keep their cached "pass" result. Consider adding that file's hash to the cache key, or note this in the rule docs.
  3. The grandfather entry looks unused. onyxLintBypass.ts:71 allows one bypass in src/setup/addUtilsToWindow.ts. That file only calls window.Onyx.get(...), which the rule does not match, and it has no disable for the read rule. If the entry isn't needed, remove it so it can't silently allow a future disable there.

Workflow (onyxGetReviewers.yml)

  1. Comments can repeat (line 84). If a reviewer drops their review request, the next push requests them again and reposts the whole comment. Check for an existing bot comment first. Also, mentions is built from REVIEWERS rather than reviewersToRequest, so when the PR author is one of the reviewers, the comment pings them.
  2. Detection is text-only (line 40). It misses aliased reads (O.get, const {get: read} = Onyx) and files with no patch in a large diff. This may be acceptable as a best-effort signal, but it's worth stating that in the file.

Repo conventions (App/CLAUDE.md)

  1. Two test files are in the wrong place. OnyxLintBypassTest.ts and OnyxLintDisableReasonTest.ts test scripts/ code. Per CLAUDE.md, they belong in tests/tooling/ and should run under bun:test, not in tests/unit/.
  2. Test cases lack comments. The cases in NoUnsafeOnyxReadRuleTest.ts have no // Given / When / Then comments, which CLAUDE.md requires. This matters most for the cases that are deliberately valid, such as selector: () => Onyx.get(...) and module-scope .then(() => Onyx.get(...)).
Scope

I reviewed the diff at cc5b636 against main, focusing on the ESLint rules, the bypass and disable-reason scripts, and the workflow. I confirmed items 2, 4, 6 and 9 in the source. The other items come from reading the code paths; I didn't run them.


view run

@LukasMod
LukasMod force-pushed the feat/onyx-get-async-mutable branch from 14c55ac to b2af92b Compare October 19, 2026 08:13
Comment thread contributingGuides/philosophies/ONYX-DATA-MANAGEMENT.md
tgolen
tgolen previously approved these changes Oct 19, 2026
chuckdries
chuckdries previously approved these changes Oct 20, 2026

@heyjennahay heyjennahay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Product review not required

Comment thread scripts/onyxLintBypass.ts
const DIRECTIVE_PATTERNS = {
disable: /^\s*eslint-disable(?<kind>-next-line|-line)?(?=\s|$)(?<args>[\s\S]*)$/,
enable: /^\s*eslint-enable(?=\s|$)(?<args>[\s\S]*)$/,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: The bypass scripts only check for eslint-disable, so these rules can still be turned off with /* eslint rulesdir/no-unsafe-onyx-read: off */. Same for no-onyx-connect. Not a huge problem but maybe we can catch that too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, added in for both rules:
5410455

Julesssss
Julesssss previously approved these changes Oct 21, 2026
@fabioh8010

Copy link
Copy Markdown
Contributor

@LukasMod Looking good to me, could you check these comments I got from Claude during my review?

Details

1. Object-literal and class-property handlers are never escalated

The highest-value finding, because the docs claim otherwise.

getFunctionBinding (no-unsafe-onyx-read.js:201-211) and getHandlerBinding (:287-306) only recognise a FunctionDeclaration or a VariableDeclarator. A function stored as an object property or a class property has no binding, so classifyFunctionBoundary falls through to DEFERRED and the read is classified as an event — even when the same file calls it during render or from an effect. Three variants, all silently clean:

const api = {load() { return Onyx.get(ONYXKEYS.SESSION); }};
export function Three() { return <div>{String(api.load())}</div>; }   // not flagged

export class Two extends React.Component {
    load = () => Onyx.get(ONYXKEYS.SESSION);
    render() { return <div>{String(this.load())}</div>; }             // not flagged
}

const handlers = {load: () => Onyx.get(ONYXKEYS.SESSION)};
useEffect(() => { void handlers.load(); }, []);                        // not flagged

Replace api.load() with a const load = () => … and all three are correctly flagged RENDER/EFFECT, so this is specifically the property-binding case.

Why it matters beyond the edge: ONYX-1's enforcement table (onyx-1-no-render-reachable-onyx-read.md:17) promises lint covers "a same-file helper that render or module scope calls", and the PR's framing is that lint takes every misuse one file's syntax decides. An object literal in the same file is exactly that. Phase 1 also has conversions described as "handler passed out (field createTransaction)" and "field toggleMultiScan", so the shape is in the target set — those happen to bind to a const first, but nothing stops the next one from being inline.

Two honest ways out: extend the bindings to Property/PropertyDefinition (resolve the object const, then look for obj.load( references; this.load( for class fields), or narrow the table and the ONYX-DATA-MANAGEMENT wording to say lint escalates only named-binding helpers, leaving object and class fields to ONYX-1 A1. Either is defensible; the current mismatch isn't, because a reviewer reading the table will assume lint has it.

2. The snapshot rule matches key paths; useOnyx redirects by string prefix

useOnyx.ts:76 redirects with key.startsWith(snapshotKey). The rule resolves the call's ONYXKEYS.X.Y path and compares it against the seven paths scraped from runtimeConfigured.ts (no-onyx-get-snapshot-key.js:41-56). Those are different predicates: any future key whose value begins with report_, policy_, transactions_, transactionViolations_, reportActions_, personalDetailsList or reportNameValuePairs_ would be redirected by the hook but allowed by the rule.

I checked every ONYXKEYS value against the seven prefixes: 7 matches, all 7 are the restricted paths themselves, so there's no leak today. It's a latent divergence, not a bug. Cheapest guard is a unit test asserting the resolved values of RESTRICTED_KEY_PATHS equal the set of ONYXKEYS values matching any snapshot prefix — it would fail the day someone adds report_draft_.

3. READ_ALLOWED_FILES is a convention with no enforcement

no-unsafe-onyx-read.js:9 is an empty set, and the error message states "A file joins READ_ALLOWED_FILES only in a PR that removes an Onyx.connectWithoutView() from it." Nothing checks that — appending a path to the set is a one-line, self-approvable change. Every other invariant in this PR is machine-checked, which makes this the one soft spot, and it's the one Phase 2 will lean on hardest. Either wire it into checkOnyxLintBypass.ts (allowlist grew ⇒ the same PR must delete a connectWithoutView from that file) or drop the promise from the message so it doesn't read as enforced.

4. ONYX-1 B1 will produce false positives on un-awaited set

ONYX-DATA-MANAGEMENT says "most writes land later, so a read queued behind a write returns the old value", and B1 flags "a write that isn't awaited … followed in the same tick by a read of the written key". That's true for merge, mergeCollection and update, but set and multiSet reach the cache before returning, so a following Onyx.get does see the new value. Reviewers applying B1 literally will flag correct code. Worth narrowing B1 to the merge family, or stating the set/multiSet exception — it's exactly the distinction the Onyx-side write-timing tests pin.

@mountiny
mountiny removed the request for review from aimane-chnaif October 21, 2026 10:03
@aimane-chnaif

Copy link
Copy Markdown
Contributor

Oh I didn't know this is urgent PR.
Can you please check https://expensify.slack.com/archives/C05LX9D6E07/p1792577039295469?thread_ts=1792490172.626419&cid=C05LX9D6E07?

@mountiny
mountiny requested a review from TaduJR October 21, 2026 10:08
mountiny
mountiny previously approved these changes Oct 21, 2026

@mountiny mountiny left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Waiting for C+ review from @TaduJR

changes look good to me

@TaduJR

TaduJR commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Reviewing...

@github-actions
github-actions Bot requested a review from luacmartins October 21, 2026 10:12
@github-actions

Copy link
Copy Markdown
Contributor

This PR adds a new Onyx.connectWithoutView call, so I've requested a review from the Onyx performance reviewers (@tgolen, @mountiny, @luacmartins, @chuckdries) — a review from any one of them is enough. Please add a link in your PR description to the Slack discussion where the @frontend-performance team approved using connectWithoutView here.

@LukasMod

Copy link
Copy Markdown
Contributor Author

I start checking feedback comments, will answer soon and look for easy solution

@TaduJR

TaduJR commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified any copy / text that was added to the app is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If the PR adds or modifies the UI:
    • I asked an AI agent to review the changes for accessibility issues and addressed its findings.
    • I tested with a screen reader (VoiceOver on macOS) and verified all new/changed elements are reachable with a logical focus order.
    • I verified all new/changed elements have meaningful accessible names and roles.
    • I verified state changes are announced (e.g. checked/unchecked, expanded/collapsed, selected).
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
Android: mWeb Chrome
iOS: HybridApp
iOS: mWeb Safari
MacOS: Chrome / Safari

@LukasMod

Copy link
Copy Markdown
Contributor Author

@TaduJR I added small update related to previous feedback

@fabioh8010 All 4 addressed:

  1. Confirmed and fixed
  2. Fixed at the root. The rule and the hook call one isSnapshotCompatibleKey, the rule compares values not paths, the parity test guards the two key lists against each other.
  3. Correct, nothing enforces it. I addressed by rewording the message so it reads as review policy
  4. Confirmed and rephrased in both docs, naming merge, mergeCollection, setCollection and update, with the set/multiSet exception

@TaduJR TaduJR left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @LukasMod. Reviewed. the Onyx side looks good expect some minor follow-ups. I will comment on Expensify/react-native-onyx#850 or post it here. It's NAB.

The enforcement has a few holes that need no eslint-disable, see inline. The by-reference reads, the seatbelt route, the useCallback render callback and the ONYX-1 contradiction should be fixed before approval.

CI cache. .github/workflows/lint.yml:73 keys the ESLint cache only on eslint.config.mjs, config/eslint/** and lint-deps-lock.json, and CI clears it only after a failed run. Changes to the rule code, or to SnapshotUtils.ts, ONYXKEYS.ts or runtimeDefaults.ts, would reuse cached passes on unchanged files. Could those paths go into the cache key?

Also could you add native screenshots (sign in, send a message, sign out and back in)? Could QA cover sign-in/out and two web tabs?


**A. Position.** Render reaches a read wherever it's written. 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 check for a type error before relying on it.

**B. Tick.** Awaiting a read doesn't wait for an earlier write. `Onyx.get` captures the cached value when it's called, and `merge` and `update` apply to the cache later, so a read queued behind them sees the old value. `set` lands at once today, but don't rely on it: 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says not to rely on set landing at once, but B1 (line 155) says a read after set is fine. Could this match B1?


const name = 'no-unsafe-onyx-read';

const READ_ALLOWED_DIRECTORIES = ['src/components/', 'src/pages/', 'src/hooks/', 'tests/'];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path check is per file, so an exported helper in src/pages/**/utils.ts can read Onyx and be imported by src/libs. Is review the intended stop for that?

}

// `useOnyx(key, {selector})` inline, or `const options = {selector}; useOnyx(key, options)`
function isHookOption(property, sourceCode) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useOnyx(key, {selector: () => Onyx.get(ONYXKEYS.SESSION)} as const) passes. Could this unwrap the cast before checking the hook argument?

* render body or at module scope, or an alias used that way), otherwise null. `classifyCall` is injected from
* classifyPosition, which this recursion feeds back into for the call sites.
*/
function getEscalatedPosition(binding, sourceCode, seen, classifyCall) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This walk and the effect walk at line 389 follow const run = load, but not useCallback(load, []), useEffectEvent(load) or a useMemo(() => () => Onyx.get(...)) factory. Reads through useCallback and the useMemo factory pass in render and in effects. Reads through useEffectEvent pass in effects.

// `const read = useCallback(() => Onyx.get(key), []); read();` in a render body
const handler = getHandlerBinding(functionNode, parent, grandparent);

return (handler && getEscalatedPosition(handler, sourceCode, seen, classifyCall)) ?? DEFERRED;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A render callback wrapped in useCallback returns DEFERRED here without the returnsJSX check, so this passes:

const renderItem = useCallback(({item}) => {
    const value = use(Onyx.get(ONYXKEYS.SESSION));
    return <Row value={value} />;
}, []);

Could this return RENDER when returnsJSX(functionNode) is true?

const {getCalledReadMethod} = createOnyxReadTracker(sourceCode);

return {
CallExpression(node) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both rules only see direct calls, so these pass with no errors, and the workflow regex misses them too:

await Onyx.get.call(Onyx, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`); // snapshot key
await Promise.all(reportKeys.map(Onyx.get));                          // snapshot keys
export const readOnyx = Onyx.get;                                      // in src/libs

Could the rules report any reference to Onyx.get/Onyx.multiGet that isn't a direct call?

Comment thread scripts/onyxLintBypass.ts
message: 'Onyx.connect() is banned and the ban cannot be bypassed with eslint-disable or an eslint config comment. Use the useOnyx() hook to read Onyx data instead.',
};

const ONYX_READ_BAN: BannedRule = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A seatbelt row also silences this rule. SEATBELT_INCREASE=rulesdir/no-unsafe-onyx-read npm run lint writes one to eslint.seatbelt.tsv, and the error becomes a warning. Could this check fail when the TSV has any row for either new rule?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants