Repository navigation
[No QA] Wrap personal details list access behind hooks, store and update builder #101075
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mountiny
merged 10 commits into
Expensify:main
from
callstack-internal:wrap-personal-details-calls
Sep 16, 2026
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
e9d3b5c
Wrap personal details list access behind hooks and store
TMisiukiewicz 00c29e1
Catch computed ONYXKEYS personal details list access
TMisiukiewicz f054a15
Add quasis to cspell dictionary
TMisiukiewicz a5ca5b6
Add header comments to new personal details files
TMisiukiewicz 7d3e86a
Warn against reusing the personal details connectWithoutView
TMisiukiewicz 07b01d7
Reuse shared personal details selectors
TMisiukiewicz 9dc5161
Merge remote-tracking branch 'origin/main' into wrap-personal-details…
TMisiukiewicz 06893f9
Merge main into wrap-personal-details-calls
TMisiukiewicz 03bf939
Merge main into wrap-personal-details-calls
TMisiukiewicz 3407d74
Clarify personal details lint allowlist comment
TMisiukiewicz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -920,6 +920,7 @@ | |
| "QAPR", | ||
| "QBWC", | ||
| "qrcode", | ||
| "quasis", | ||
| "quotepath", | ||
| "rach", | ||
| "reactnative", | ||
|
|
||
61 changes: 61 additions & 0 deletions
61
eslint-plugin-local-rules/no-direct-personal-details-list.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| const name = 'no-direct-personal-details-list'; | ||
|
|
||
| const meta = { | ||
| type: 'problem', | ||
| docs: { | ||
| description: | ||
| 'Disallow direct use of `ONYXKEYS.PERSONAL_DETAILS_LIST`. Personal details are being reshaped into an Onyx collection, so access has to go through a wrapper that can absorb the change.', | ||
| recommended: 'error', | ||
| }, | ||
| schema: [], | ||
| messages: { | ||
| directUsage: | ||
| '`ONYXKEYS.PERSONAL_DETAILS_LIST` is migrating to an Onyx collection, so do not reference it directly. In React use @hooks/usePersonalDetails, outside React use @libs/PersonalDetailsStore, and for writes use `buildPersonalDetailsUpdate` from @libs/PersonalDetailsUtils.', | ||
| }, | ||
| }; | ||
|
|
||
| const ONYXKEYS_IDENTIFIER = 'ONYXKEYS'; | ||
| const PERSONAL_DETAILS_LIST_PROPERTY = 'PERSONAL_DETAILS_LIST'; | ||
|
|
||
| /** | ||
| * Resolves the property name of a member expression when it is statically known, so `ONYXKEYS.PERSONAL_DETAILS_LIST`, | ||
| * `ONYXKEYS['PERSONAL_DETAILS_LIST']` and ``ONYXKEYS[`PERSONAL_DETAILS_LIST`]`` are all treated as the same access. | ||
| * A computed access through a variable stays unresolved, because its value is only known at runtime. | ||
| * | ||
| * @param {import('estree').MemberExpression} node | ||
| * @returns {string | undefined} | ||
| */ | ||
| function getStaticPropertyName(node) { | ||
| if (!node.computed) { | ||
| return node.property.type === 'Identifier' ? node.property.name : undefined; | ||
| } | ||
| if (node.property.type === 'Literal') { | ||
| return typeof node.property.value === 'string' ? node.property.value : undefined; | ||
| } | ||
| if (node.property.type === 'TemplateLiteral' && node.property.expressions.length === 0 && node.property.quasis.length === 1) { | ||
| return node.property.quasis[0].value.cooked; | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * @param {import('eslint').Rule.RuleContext} context | ||
| * @returns {import('eslint').Rule.RuleListener} | ||
| */ | ||
| function create(context) { | ||
| return { | ||
| // Type positions parse as TSTypeQuery, not MemberExpression, so `typeof ONYXKEYS.PERSONAL_DETAILS_LIST` | ||
| // is allowed: it neither reads nor writes, and it follows the key definition when that is reshaped. | ||
| MemberExpression(node) { | ||
| if (node.object.type !== 'Identifier' || node.object.name !== ONYXKEYS_IDENTIFIER) { | ||
| return; | ||
| } | ||
| if (getStaticPropertyName(node) !== PERSONAL_DETAILS_LIST_PROPERTY) { | ||
| return; | ||
| } | ||
| context.report({node, messageId: 'directUsage'}); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| export {name, meta, create}; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| /** Personal details reads for components, so nothing touches PERSONAL_DETAILS_LIST directly */ | ||
| import ONYXKEYS from '@src/ONYXKEYS'; | ||
| import {personalDetailsListSelector, personalDetailsSelector} from '@src/selectors/PersonalDetails'; | ||
| import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; | ||
|
|
||
| import type {UseOnyxResult} from 'react-native-onyx'; | ||
|
|
||
| import useOnyx from './useOnyx'; | ||
|
|
||
| function usePersonalDetail(accountID: number | undefined): UseOnyxResult<PersonalDetails | undefined> { | ||
| return useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsSelector(accountID)}); | ||
| } | ||
|
|
||
| function usePersonalDetailsByIDs(accountIDs: Array<number | undefined> | undefined): UseOnyxResult<PersonalDetailsList> { | ||
| return useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsListSelector(accountIDs)}); | ||
| } | ||
|
|
||
| // Re-renders on any account change, prefer usePersonalDetail/usePersonalDetailsByIDs when the accounts are known | ||
| function useAllPersonalDetails(): UseOnyxResult<PersonalDetailsList | undefined>; | ||
| function useAllPersonalDetails<TReturn>(selector: (value: PersonalDetailsList | undefined) => TReturn): UseOnyxResult<TReturn>; | ||
| function useAllPersonalDetails<TReturn>(selector?: (value: PersonalDetailsList | undefined) => TReturn) { | ||
| return useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector}); | ||
| } | ||
|
|
||
| export {usePersonalDetail, usePersonalDetailsByIDs, useAllPersonalDetails}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| /** Personal details for non-React callers, and the one place the coming Onyx-collection reshape has to land */ | ||
| import ONYXKEYS from '@src/ONYXKEYS'; | ||
|
TMisiukiewicz marked this conversation as resolved.
Resolved
|
||
| import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; | ||
|
|
||
| import Onyx from 'react-native-onyx'; | ||
|
|
||
| let allPersonalDetails: PersonalDetailsList = {}; | ||
| let loginToPersonalDetails: Record<string, PersonalDetails> = {}; | ||
|
|
||
| function buildLoginMapping(personalDetailsList: PersonalDetailsList): Record<string, PersonalDetails> { | ||
| const mapping: Record<string, PersonalDetails> = {}; | ||
| for (const personalDetail of Object.values(personalDetailsList)) { | ||
| if (!personalDetail?.login) { | ||
| continue; | ||
| } | ||
| const login = personalDetail.login.toLowerCase(); | ||
| const existing = mapping[login]; | ||
| if (!existing || existing.isClosed || existing.isOptimisticPersonalDetail) { | ||
| mapping[login] = personalDetail; | ||
| } | ||
| } | ||
| return mapping; | ||
| } | ||
|
|
||
| // Only sanctioned connectWithoutView for personal details, don't add another: React uses @hooks/usePersonalDetails, everything else uses this store | ||
| Onyx.connectWithoutView({ | ||
| key: ONYXKEYS.PERSONAL_DETAILS_LIST, | ||
| callback: (value) => { | ||
| allPersonalDetails = value ?? {}; | ||
| loginToPersonalDetails = buildLoginMapping(allPersonalDetails); | ||
| }, | ||
| }); | ||
|
|
||
| function getAllPersonalDetails(): PersonalDetailsList { | ||
| return allPersonalDetails; | ||
| } | ||
|
|
||
| function getPersonalDetail(accountID: number | undefined): PersonalDetails | undefined { | ||
| return accountID ? (allPersonalDetails[accountID] ?? undefined) : undefined; | ||
| } | ||
|
|
||
| function getPersonalDetailByLogin(login: string | undefined): PersonalDetails | undefined { | ||
| if (!login) { | ||
| return undefined; | ||
| } | ||
| return loginToPersonalDetails[login.toLowerCase()]; | ||
| } | ||
|
|
||
| export {getAllPersonalDetails, getPersonalDetail, getPersonalDetailByLogin}; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import type {Rule} from 'eslint'; | ||
|
|
||
| import {RuleTester} from 'eslint'; | ||
| import {parser as tsParser} from 'typescript-eslint'; | ||
|
|
||
| type LocalRuleModule = Rule.RuleModule & { | ||
| name: string; | ||
| }; | ||
|
|
||
| function isLocalRuleModule(ruleModule: unknown): ruleModule is LocalRuleModule { | ||
| if (typeof ruleModule !== 'object' || ruleModule === null) { | ||
| return false; | ||
| } | ||
|
|
||
| const ruleName: unknown = Reflect.get(ruleModule, 'name'); | ||
| const create: unknown = Reflect.get(ruleModule, 'create'); | ||
| const meta: unknown = Reflect.get(ruleModule, 'meta'); | ||
|
|
||
| return typeof ruleName === 'string' && typeof create === 'function' && typeof meta === 'object' && meta !== null; | ||
| } | ||
|
|
||
| const ruleModule: unknown = require('../../eslint-plugin-local-rules/no-direct-personal-details-list'); | ||
|
|
||
| if (!isLocalRuleModule(ruleModule)) { | ||
| throw new TypeError('Expected no-direct-personal-details-list to export an ESLint rule module.'); | ||
| } | ||
|
|
||
| const tsRuleTester = new RuleTester({ | ||
| languageOptions: { | ||
| ecmaVersion: 2022, | ||
| sourceType: 'module', | ||
| parser: tsParser, | ||
| parserOptions: { | ||
| ecmaFeatures: { | ||
| jsx: true, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| describe('no-direct-personal-details-list', () => { | ||
| tsRuleTester.run(ruleModule.name, ruleModule, { | ||
| valid: [ | ||
| 'const [personalDetail] = usePersonalDetail(accountID);', | ||
| 'const [personalDetails] = usePersonalDetailsByIDs(accountIDs);', | ||
| 'const personalDetail = getPersonalDetail(accountID);', | ||
| // Type positions parse as TSTypeQuery, not MemberExpression. | ||
| 'type Update = OnyxUpdate<typeof ONYXKEYS.PERSONAL_DETAILS_LIST>;', | ||
| 'type Data = Partial<Record<typeof ONYXKEYS.PERSONAL_DETAILS_LIST, PersonalDetailsList>>;', | ||
| 'const [metadata] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_METADATA);', | ||
| 'const [list] = useOnyx(ONYXKEYS.COLLECTION.REPORT);', | ||
| 'const key = SOME_OTHER_KEYS.PERSONAL_DETAILS_LIST;', | ||
| // Only known at runtime, so there is nothing to resolve statically. | ||
| 'const key = ONYXKEYS[PERSONAL_DETAILS_LIST];', | ||
| ], | ||
| invalid: [ | ||
| { | ||
| code: 'const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);', | ||
| errors: [{messageId: 'directUsage'}], | ||
| }, | ||
| { | ||
| code: 'const [login] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(accountID)});', | ||
| errors: [{messageId: 'directUsage'}], | ||
| }, | ||
| { | ||
| code: "const [personalDetails] = useOnyx(ONYXKEYS['PERSONAL_DETAILS_LIST']);", | ||
| errors: [{messageId: 'directUsage'}], | ||
| }, | ||
| { | ||
| code: 'const [personalDetails] = useOnyx(ONYXKEYS[`PERSONAL_DETAILS_LIST`]);', | ||
| errors: [{messageId: 'directUsage'}], | ||
| }, | ||
| { | ||
| code: 'Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[accountID]: null});', | ||
| errors: [{messageId: 'directUsage'}], | ||
| }, | ||
| { | ||
| code: 'const update = {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: personalDetails};', | ||
| errors: [{messageId: 'directUsage'}], | ||
| }, | ||
| { | ||
| code: 'Onyx.connectWithoutView({key: ONYXKEYS.PERSONAL_DETAILS_LIST, callback: (value) => value});', | ||
| errors: [{messageId: 'directUsage'}], | ||
| }, | ||
| { | ||
| code: 'const masks = {[ONYXKEYS.PERSONAL_DETAILS_LIST]: {allowList: []}};', | ||
| errors: [{messageId: 'directUsage'}], | ||
| }, | ||
| { | ||
| // Reported per usage, so the seatbelt count is per usage too. | ||
| code: 'const [all] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {});', | ||
| errors: [{messageId: 'directUsage'}, {messageId: 'directUsage'}], | ||
| }, | ||
| ], | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.