From cdcf3da00b27dfc2e8604ab6a92eaa5786b17ba6 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:22:43 +0000 Subject: [PATCH 1/3] Remove unsafe assertions from input, context, search, and DOM helpers Seatbelt-Operation: adc93f02-11ed-4ef2-bace-381d00d62b1f --- .../Avatar/connected/InvoiceRoomAvatar.tsx | 2 +- .../Avatar/connected/useSortedIcons.ts | 2 +- src/components/TextInput/index.tsx | 12 +- src/components/ValidateCodeInput.tsx | 9 +- src/components/createOnyxContext.tsx | 14 +- src/hooks/useFilterFormValues.tsx | 14 +- src/hooks/usePermissions.ts | 5 +- src/libs/DomUtils/index.ts | 10 +- src/libs/PolicyUtils/tax.ts | 2 +- src/libs/SearchQueryUtils.ts | 18 +- tests/ui/TextInputWebRefAndFocusTest.tsx | 220 ++++++++++++++ tests/ui/ValidateCodeInputEventTest.tsx | 275 ++++++++++++++++++ tests/unit/DomUtilsWebTest.ts | 68 +++++ tests/unit/Search/SearchQueryUtilsTest.ts | 38 +++ .../connected/InvoiceRoomAvatarTest.tsx | 23 +- .../Avatar/connected/useSortedIconsTest.ts | 50 +++- tests/unit/createOnyxContextTest.tsx | 98 +++++++ tests/unit/hooks/useFilterFormValues.test.ts | 105 ++++++- tests/unit/usePermissionsTest.tsx | 22 +- 19 files changed, 943 insertions(+), 44 deletions(-) create mode 100644 tests/ui/TextInputWebRefAndFocusTest.tsx create mode 100644 tests/ui/ValidateCodeInputEventTest.tsx create mode 100644 tests/unit/DomUtilsWebTest.ts create mode 100644 tests/unit/createOnyxContextTest.tsx diff --git a/src/components/Avatar/connected/InvoiceRoomAvatar.tsx b/src/components/Avatar/connected/InvoiceRoomAvatar.tsx index ce52dec0117f..a49edd4e2f01 100644 --- a/src/components/Avatar/connected/InvoiceRoomAvatar.tsx +++ b/src/components/Avatar/connected/InvoiceRoomAvatar.tsx @@ -99,7 +99,7 @@ function InvoiceRoomAvatar({ const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(invoiceReceiverPolicyID)}`, {selector: policyAvatarFieldsSelector}); // The unknown account, standing in for a missing receiver. - const receiverIcon = getInvoiceReceiverIcon(invoiceReceiver, invoiceReceiverPolicy, personalDetails) ?? { + const receiverIcon = getInvoiceReceiverIcon(invoiceReceiver, invoiceReceiverPolicy, personalDetails ?? undefined) ?? { id: CONST.DEFAULT_NUMBER_ID, type: CONST.ICON_TYPE_AVATAR, source: defaultAvatars.FallbackAvatar, diff --git a/src/components/Avatar/connected/useSortedIcons.ts b/src/components/Avatar/connected/useSortedIcons.ts index 0bfadcb83a63..08954cb61a5d 100644 --- a/src/components/Avatar/connected/useSortedIcons.ts +++ b/src/components/Avatar/connected/useSortedIcons.ts @@ -23,7 +23,7 @@ function useSortedIcons(icons: Icon[], sort?: SortingOption | SortingOption[]): if (sortBy.includes(CONST.REPORT_ACTION_AVATARS.SORT_BY.NAME)) { // Read personal details only for a name sort, so other stacks don't re-render on every personal details change. // `sortIconsByName` sorts in place, so sort a copy and leave the given array as it was - sortedIcons = sortIconsByName([...icons], use(PersonalDetailsContext), localeCompare); + sortedIcons = sortIconsByName([...icons], use(PersonalDetailsContext) ?? undefined, localeCompare); } else if (sortBy.includes(CONST.REPORT_ACTION_AVATARS.SORT_BY.ID)) { sortedIcons = lodashSortBy(icons, (icon) => icon.id); } diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx index ed79fd7f9af5..3cad8ed707e2 100644 --- a/src/components/TextInput/index.tsx +++ b/src/components/TextInput/index.tsx @@ -6,11 +6,11 @@ import Visibility from '@libs/Visibility'; import CONST from '@src/CONST'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {ViewStyle} from 'react-native'; import React, {useEffect, useRef} from 'react'; -import type {BaseTextInputProps} from './BaseTextInput/types'; +import type {BaseTextInputProps, BaseTextInputRef} from './BaseTextInput/types'; import BaseTextInput from './BaseTextInput'; import * as styleConst from './styleConst'; @@ -23,7 +23,7 @@ function getIsRestoringKeyboardFocus() { function TextInput({ref, ...props}: BaseTextInputProps) { const styles = useThemeStyles(); - const textInputRef = useRef(null); + const textInputRef = useRef(null); const removeVisibilityListenerRef = useRef<(() => void) | null>(null); useEffect(() => { @@ -88,7 +88,7 @@ function TextInput({ref, ...props}: BaseTextInputProps) { }, []); const isLabeledMultiline = !!props.label?.length && props.multiline; - const labelAnimationStyle = { + const labelAnimationStyle: ViewStyle & Record<'--active-label-translate-y' | '--active-label-scale' | '--label-transition-duration', string> = { // eslint-disable-next-line @typescript-eslint/naming-convention '--active-label-translate-y': `${styleConst.ACTIVE_LABEL_TRANSLATE_Y}px`, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -101,7 +101,7 @@ function TextInput({ref, ...props}: BaseTextInputProps) { { - textInputRef.current = element as HTMLFormElement; + textInputRef.current = element; if (!ref) { return; @@ -116,7 +116,7 @@ function TextInput({ref, ...props}: BaseTextInputProps) { ref.current = element; }} inputStyle={[styles.baseTextInput, styles.textInputDesktop, isLabeledMultiline ? styles.textInputMultiline : {}, props.inputStyle]} - textInputContainerStyles={[labelAnimationStyle as StyleProp, props.textInputContainerStyles, styles.cursorText]} + textInputContainerStyles={[labelAnimationStyle, props.textInputContainerStyles, styles.cursorText]} /> ); } diff --git a/src/components/ValidateCodeInput.tsx b/src/components/ValidateCodeInput.tsx index 8b96e387bf4d..0d0095ffbbf6 100644 --- a/src/components/ValidateCodeInput.tsx +++ b/src/components/ValidateCodeInput.tsx @@ -9,8 +9,8 @@ import {isNumeric} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; -import type {ComponentRef, ForwardedRef, KeyboardEvent} from 'react'; -import type {FocusEvent, TextInput as RNTextInput, TextInputKeyPressEvent} from 'react-native'; +import type {ForwardedRef} from 'react'; +import type {FocusEvent, TextInputKeyPressEvent} from 'react-native'; import type {ValueOf} from 'type-fest'; import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; @@ -23,6 +23,7 @@ import type {BaseTextInputRef} from './TextInput/BaseTextInput/types'; import FormHelpMessage from './FormHelpMessage'; import Text from './Text'; import TextInput from './TextInput'; +import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; const TEXT_INPUT_EMPTY_STATE = ''; @@ -40,7 +41,7 @@ const useValidateCodePaste = (inputRef: React.RefObject return; } - const isFocused = (inputRef.current as ComponentRef)?.isFocused?.() ?? false; + const isFocused = isTextInputFocused(inputRef); if (!isFocused) { return; } @@ -414,7 +415,7 @@ function ValidateCodeInput({ setInput(TEXT_INPUT_EMPTY_STATE); onFulfill(value); } else if (keyValue === 'Tab' && focusedIndex !== undefined) { - const newFocusedIndex = (event as unknown as KeyboardEvent).shiftKey ? focusedIndex - 1 : focusedIndex + 1; + const newFocusedIndex = 'shiftKey' in event && event.shiftKey ? focusedIndex - 1 : focusedIndex + 1; if (newFocusedIndex >= 0 && newFocusedIndex < maxLength) { setInputAndIndex(newFocusedIndex); inputRef.current?.focus(); diff --git a/src/components/createOnyxContext.tsx b/src/components/createOnyxContext.tsx index 226ae5602d68..6ef2abacc6b5 100644 --- a/src/components/createOnyxContext.tsx +++ b/src/components/createOnyxContext.tsx @@ -4,26 +4,28 @@ import type {OnyxKey} from '@src/ONYXKEYS'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {ComponentType, Context, ReactNode} from 'react'; -import type {OnyxValue} from 'react-native-onyx'; +import type {OnyxValue, UseOnyxResult} from 'react-native-onyx'; import {Str} from 'expensify-common'; import React, {createContext, useContext} from 'react'; +type OnyxContextValue = UseOnyxResult>[0]; + // createOnyxContext return type -type CreateOnyxContext = [ComponentType, React.Context>, () => OnyxValue]; +type CreateOnyxContext = [ComponentType, Context | null>, () => OnyxContextValue]; type OnyxContextProviderImplProps = ChildrenProps & { onyxKeyName: TOnyxKey; - Context: Context>; + Context: Context | null>; }; function OnyxContextProviderImpl({onyxKeyName, Context: OnyxContext, children}: OnyxContextProviderImplProps): ReactNode { const [value] = useOnyx(onyxKeyName); - return }>{children}; + return {children}; } export default (onyxKeyName: TOnyxKey): CreateOnyxContext => { - const Context = createContext>(null as OnyxValue); + const Context = createContext | null>(null); function Provider(props: ChildrenProps): ReactNode { return ( (onyxKeyName: TOnyxKey): CreateOnyxCont if (value === null) { throw new Error(`useOnyxContext must be used within a OnyxListItemProvider [key: ${onyxKeyName}]`); } - return value as OnyxValue; + return value; }; return [Provider, Context, useOnyxContext]; diff --git a/src/hooks/useFilterFormValues.tsx b/src/hooks/useFilterFormValues.tsx index e72d1abf66db..4bfac4c17aa6 100644 --- a/src/hooks/useFilterFormValues.tsx +++ b/src/hooks/useFilterFormValues.tsx @@ -20,32 +20,32 @@ import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useExportedToFilterOptions from './useExportedToFilterOptions'; import useOnyx from './useOnyx'; -function policiesSelector(policies: OnyxCollection): OnyxCollection { +function policiesSelector(policies: OnyxCollection): OnyxCollection> { if (!policies) { return policies; } - const result: OnyxCollection = {}; + const result: OnyxCollection> = {}; for (const [key, policy] of Object.entries(policies)) { if (!policy) { continue; } - result[key] = {taxRates: policy.taxRates} as Policy; + result[key] = {taxRates: policy.taxRates}; } return result; } -function policyCategoriesSelector(categories: OnyxCollection): OnyxCollection { +function policyCategoriesSelector(categories: OnyxCollection): OnyxCollection>> { if (!categories) { return categories; } - const result: OnyxCollection = {}; + const result: OnyxCollection>> = {}; for (const [collectionKey, policyCategories] of Object.entries(categories)) { if (!policyCategories) { continue; } - const minimalCategories: PolicyCategories = {}; + const minimalCategories: Record> = {}; for (const [catKey, category] of Object.entries(policyCategories)) { - minimalCategories[catKey] = {name: category.name} as PolicyCategories[string]; + minimalCategories[catKey] = {name: category.name}; } result[collectionKey] = minimalCategories; } diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index cb16cc0af867..2f2251dd8365 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -23,11 +23,12 @@ export default function usePermissions(): UsePermissions { return useMemo(() => { const permissions: UsePermissions = { - isBetaEnabled: (beta: Beta) => Permissions.isBetaEnabled(beta, betas, betaConfiguration, betaOverrides, environment), + isBetaEnabled: (beta: Beta) => Permissions.isBetaEnabled(beta, betas ?? undefined, betaConfiguration ?? undefined, betaOverrides ?? undefined, environment), // Prefer isBetaEnabled. This exists for the consumers that must tell "off" apart from "not loaded yet", // currently the inactive vendor violation and the approval mode next steps, reached from many components - isBetaEnabledOrUnknown: (beta: Beta) => (betas === undefined ? undefined : Permissions.isBetaEnabled(beta, betas, betaConfiguration, betaOverrides, environment)), + isBetaEnabledOrUnknown: (beta: Beta) => + betas === undefined ? undefined : Permissions.isBetaEnabled(beta, betas ?? undefined, betaConfiguration ?? undefined, betaOverrides ?? undefined, environment), }; for (permissionKey in Permissions) { diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts index 99ebda35ede8..80cced277ed0 100644 --- a/src/libs/DomUtils/index.ts +++ b/src/libs/DomUtils/index.ts @@ -8,7 +8,10 @@ const addCSS = (css: string, styleId: string) => { if (existingStyle) { if ('styleSheet' in existingStyle) { // Supports IE8 and below - (existingStyle.styleSheet as CSSStyleDeclaration).cssText = css; + const {styleSheet} = existingStyle; + if (typeof styleSheet === 'object' && styleSheet !== null && 'cssText' in styleSheet) { + styleSheet.cssText = css; + } } else { existingStyle.innerHTML = css; } @@ -19,7 +22,10 @@ const addCSS = (css: string, styleId: string) => { if ('styleSheet' in styleElement) { // Supports IE8 and below - (styleElement.styleSheet as CSSStyleDeclaration).cssText = css; + const {styleSheet} = styleElement; + if (typeof styleSheet === 'object' && styleSheet !== null && 'cssText' in styleSheet) { + styleSheet.cssText = css; + } } else { styleElement.appendChild(document.createTextNode(css)); } diff --git a/src/libs/PolicyUtils/tax.ts b/src/libs/PolicyUtils/tax.ts index 4132a49dcea6..c120ccc6c74d 100644 --- a/src/libs/PolicyUtils/tax.ts +++ b/src/libs/PolicyUtils/tax.ts @@ -41,7 +41,7 @@ function getTaxByID(policy: OnyxEntry, taxID: string): TaxRate | undefin /** Get a tax rate object built like Record. * We want to allow user to choose over TaxRateName and there might be a situation when one TaxRateName has two possible keys in different policies */ -function getAllTaxRatesNamesAndKeys(policies: OnyxCollection): Record { +function getAllTaxRatesNamesAndKeys(policies: OnyxCollection>): Record { const allTaxRates: Record = {}; const seenKeys: Record> = {}; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 5f3f9868ec96..8bb5fe05f489 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -1583,7 +1583,19 @@ function getAllPolicyValues( key: T, policyData: OnyxCollection, policies?: OnyxCollection, -): Array { +): Array; +function getAllPolicyValues( + policyID: Filter | undefined, + key: typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES, + policyData: OnyxCollection>>, + policies?: OnyxCollection, +): Array>>; +function getAllPolicyValues( + policyID: Filter | undefined, + key: T, + policyData: OnyxCollection>>, + policies?: OnyxCollection, +): Array>> { if (!policyData || !policyID || !policyID.value) { return Object.values(policyData ?? {}).filter((data): data is NonNullable => !!data); } @@ -1600,7 +1612,7 @@ function getAllPolicyValues( } return acc; }, - [] as Array, + [] as Array>>, ); } @@ -1810,7 +1822,7 @@ function getDateRangeForPreset(preset: SearchDatePreset): {start: string; end: s // Adds bankAccountList and currentUserAccountID for the new bank account and from:me filters. Refactoring this to a params object would touch every call site and is out of scope here. function buildFilterFormValuesFromQuery( queryJSON: SearchQueryJSON, - policyCategories: OnyxCollection, + policyCategories: OnyxCollection>>, currencyList: OnyxTypes.CurrencyList, personalDetails: OnyxTypes.PersonalDetailsList | undefined, cardList: OnyxTypes.CardList | undefined, diff --git a/tests/ui/TextInputWebRefAndFocusTest.tsx b/tests/ui/TextInputWebRefAndFocusTest.tsx new file mode 100644 index 000000000000..095be4a589e4 --- /dev/null +++ b/tests/ui/TextInputWebRefAndFocusTest.tsx @@ -0,0 +1,220 @@ +/** Exercises the web wrapper with DOM-backed input refs while keeping its effects and focus timers real. */ +import {act, cleanup, render} from '@testing-library/react-native'; + +import type * as WebTextInput from '@components/TextInput'; +import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; + +import {isMobileChrome} from '@libs/Browser'; +import type * as WebDomUtils from '@libs/DomUtils'; +import Visibility from '@libs/Visibility'; + +import CONST from '@src/CONST'; + +import React, {createRef} from 'react'; +import {StyleSheet} from 'react-native'; + +import createMock from '../utils/createMock'; + +const {default: TextInput, getIsRestoringKeyboardFocus} = jest.requireActual('../../src/components/TextInput/index.tsx'); + +const mockCaptureInputProps = jest.fn(); +let mockInput: HTMLInputElement | HTMLTextAreaElement; +let mockInputRef: BaseTextInputRef; +let mockVisibilityChange: (() => void) | undefined; +const mockRemoveVisibilityListener = jest.fn(); + +jest.mock('@libs/DomUtils', () => jest.requireActual('../../src/libs/DomUtils/index.ts')); +jest.mock('@libs/Browser', () => ({isMobileChrome: jest.fn(() => true)})); +jest.mock('@libs/Visibility', () => ({ + __esModule: true, + default: { + isVisible: jest.fn(() => true), + onVisibilityChange: jest.fn((listener: () => void) => { + mockVisibilityChange = listener; + return mockRemoveVisibilityListener; + }), + }, +})); +jest.mock('@hooks/useThemeStyles', () => () => ({ + baseTextInput: {color: 'red'}, + textInputDesktop: {color: 'blue'}, + textInputMultiline: {color: 'green'}, + cursorText: {cursor: 'text'}, +})); +jest.mock('@components/TextInput/BaseTextInput', () => { + const ReactMock = jest.requireActual('react'); + function MockBaseTextInput(props: BaseTextInputProps) { + mockCaptureInputProps(props); + ReactMock.useLayoutEffect(() => { + const inputRef = props.ref; + if (typeof inputRef === 'function') { + inputRef(mockInputRef); + return () => inputRef(null); + } + return undefined; + }, [props.ref]); + return null; + } + return {__esModule: true, default: MockBaseTextInput}; +}); + +function getWebInputProps() { + const props = mockCaptureInputProps.mock.calls.at(-1)?.[0]; + expect(props).toBeDefined(); + if (!props) { + throw new Error('The web wrapper did not render BaseTextInput'); + } + return props; +} + +function notifyVisibility() { + expect(mockVisibilityChange).toBeDefined(); + act(() => mockVisibilityChange?.()); +} + +describe('web TextInput refs and focus', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + jest.mocked(isMobileChrome).mockReturnValue(true); + jest.mocked(Visibility.isVisible).mockReturnValue(true); + mockInput = document.createElement('input'); + // The test-only partial ref keeps the real DOM node identity without fabricating an animated native instance. + const inputMethods: Pick = mockInput; + mockInputRef = createMock(inputMethods); + document.body.appendChild(mockInput); + }); + afterEach(() => { + cleanup(); + mockInput.remove(); + jest.clearAllTimers(); + }); + + it.each([false, true])('forwards the exact DOM node and null detach for multiline: %s', (multiline) => { + // Given either web input variant and a callback ref + if (multiline) { + mockInput.remove(); + mockInput = document.createElement('textarea'); + const inputMethods: Pick = mockInput; + mockInputRef = createMock(inputMethods); + document.body.appendChild(mockInput); + } + const ref = jest.fn(); + // When the wrapper mounts with keyboard and name attributes + const {unmount} = render( + , + ); + // Then attributes and ref identity refer to that same node, including detach + expect(ref).toHaveBeenLastCalledWith(mockInput); + expect(mockInput.getAttribute('inputmode')).toBe('none'); + expect(mockInput.getAttribute('name')).toBe('code'); + unmount(); + expect(ref).toHaveBeenLastCalledWith(null); + }); + + it('keeps object ref identity and caller style precedence', () => { + // Given a labeled multiline input with caller overrides + const ref = createRef(); + // When the wrapper assembles the styles + const {unmount} = render( + , + ); + const props = getWebInputProps(); + // Then the exact three CSS properties precede caller styles and the final cursor style + expect(ref.current).toBe(mockInput); + expect(props.textInputContainerStyles).toEqual([ + Object.fromEntries([ + ['--active-label-translate-y', '7px'], + ['--active-label-scale', '0.8668'], + ['--label-transition-duration', '80ms'], + ]), + {cursor: 'pointer'}, + {cursor: 'text'}, + ]); + expect(StyleSheet.flatten(props.inputStyle)).toEqual({color: 'purple'}); + unmount(); + expect(ref.current).toBeNull(); + }); + + it('restores the active input after the delay and resets the shared flag', () => { + // Given the active field when mobile Chrome returns to the foreground + render(); + mockInput.focus(); + // When visibility changes, duplicate notifications must not restart restoration + notifyVisibility(); + expect(document.activeElement).toBe(document.body); + expect(getIsRestoringKeyboardFocus()).toBe(true); + act(() => window.dispatchEvent(new Event('focus'))); + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION - 1)); + expect(document.activeElement).toBe(document.body); + act(() => jest.advanceTimersByTime(1)); + // Then focus returns only at the prescribed delay and the flag survives until its own delay + expect(document.activeElement).toBe(mockInput); + expect(getIsRestoringKeyboardFocus()).toBe(true); + act(() => jest.advanceTimersByTime(CONST.KEYBOARD_RESTORATION_FLAG_RESET_DELAY)); + expect(getIsRestoringKeyboardFocus()).toBe(false); + }); + + it('does not steal focus when another field becomes active during restoration', () => { + // Given a restoration started for the original active input + render(); + mockInput.focus(); + notifyVisibility(); + const otherInput = document.createElement('input'); + document.body.appendChild(otherInput); + otherInput.focus(); + // When the restore delay expires after focus moved elsewhere + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); + // Then the other field remains active and the cancellation resets the flag + expect(document.activeElement).toBe(otherInput); + expect(getIsRestoringKeyboardFocus()).toBe(false); + otherInput.remove(); + }); + + it.each(['inactive', 'hidden', 'other-browser'])('does not begin restoration for %s', (condition) => { + // Given one of the preconditions for restoration is absent + jest.mocked(isMobileChrome).mockReturnValue(condition !== 'other-browser'); + jest.mocked(Visibility.isVisible).mockReturnValue(condition !== 'hidden'); + render(); + if (condition !== 'inactive') { + mockInput.focus(); + } + const activeElement = document.activeElement; + // When visibility is announced + notifyVisibility(); + // Then no blur or restoration timer begins + expect(document.activeElement).toBe(activeElement); + expect(getIsRestoringKeyboardFocus()).toBe(false); + }); + + it.each([false, true])('clears pending timers and both listeners on detach after refocus: %s', (afterRefocus) => { + // Given a pending focus or flag-reset timer + const {unmount} = render(); + mockInput.focus(); + act(() => window.dispatchEvent(new Event('focus'))); + if (afterRefocus) { + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); + } + // When the wrapper unmounts + unmount(); + // Then neither timeout can outlive the field and the visibility subscription is removed + expect(jest.getTimerCount()).toBe(0); + expect(mockRemoveVisibilityListener).toHaveBeenCalledTimes(1); + expect(getIsRestoringKeyboardFocus()).toBe(false); + act(() => window.dispatchEvent(new Event('focus'))); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/tests/ui/ValidateCodeInputEventTest.tsx b/tests/ui/ValidateCodeInputEventTest.tsx new file mode 100644 index 000000000000..42b4c3994a3b --- /dev/null +++ b/tests/ui/ValidateCodeInputEventTest.tsx @@ -0,0 +1,275 @@ +/** Runs validation-code paste, navigation and native digit-pad events through the production handlers. */ +import {act, cleanup, render} from '@testing-library/react-native'; + +import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import ValidateCodeInput from '@components/ValidateCodeInput'; +import type {ValidateCodeInputHandle, ValidateCodeInputProps} from '@components/ValidateCodeInput'; + +import type useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import type useNetwork from '@hooks/useNetwork'; + +import type {TextInputKeyPressEvent} from 'react-native'; + +import React, {createRef} from 'react'; + +import createMock from '../utils/createMock'; + +const mockCaptureCodeInputProps = jest.fn(); +let mockInput: BaseTextInputRef | null; +let mockFocused = true; +let mockOffline = false; + +jest.mock('@hooks/useNetwork', () => () => ({isOffline: mockOffline}) satisfies Pick, 'isOffline'>); +jest.mock('@hooks/useAccessibilityAnnouncement', () => jest.fn, Parameters>()); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@components/TextInput', () => { + const ReactMock = jest.requireActual('react'); + function MockCodeInput(props: BaseTextInputProps) { + mockCaptureCodeInputProps(props); + ReactMock.useLayoutEffect(() => { + const inputRef = props.ref; + if (typeof inputRef === 'function') { + inputRef(mockInput); + return () => inputRef(null); + } + return undefined; + }, [props.ref]); + return null; + } + return {__esModule: true, default: MockCodeInput}; +}); + +function getCodeInputProps() { + const props = mockCaptureCodeInputProps.mock.calls.at(-1)?.[0]; + expect(props).toBeDefined(); + if (!props) { + throw new Error('ValidateCodeInput did not render its backing input'); + } + return props; +} + +function pasteCode(text: string) { + const event = new Event('paste', {bubbles: true, cancelable: true}); + // jsdom does not supply ClipboardEvent/DataTransfer. Only this clipboard contribution is substituted. + Object.defineProperty(event, 'clipboardData', {value: {getData: (format: string) => (format === 'text/plain' ? text : '')}}); + act(() => document.dispatchEvent(event)); + return event; +} + +function pressCodeKey(key: string, shiftKey?: boolean, nativeShiftKey?: boolean) { + const props = getCodeInputProps(); + expect(props.onKeyPress).toBeDefined(); + const preventDefault = jest.fn, Parameters>(); + const nativeEvent = Object.assign(createMock({key}), nativeShiftKey === undefined ? {} : {shiftKey: nativeShiftKey}); + const event = Object.assign(createMock({nativeEvent, preventDefault}), shiftKey === undefined ? {} : {shiftKey}); + act(() => props.onKeyPress?.(event)); + return preventDefault; +} + +describe('ValidateCodeInput production events', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFocused = true; + mockOffline = false; + // The native fixture helper retains the DOM methods and focused-state contribution used by the real handlers. + const inputMethods: Pick = Object.assign(document.createElement('input'), {isFocused: () => mockFocused}); + mockInput = createMock(inputMethods); + }); + afterEach(() => cleanup()); + + it('trims focused numeric paste and removes the listener when unmounted', () => { + // Given a focused code field and whitespace from a copied email + const onChangeText = jest.fn>, Parameters>>(); + const {unmount} = render( + , + ); + // When the real document paste handler receives a code + const event = pasteCode(' 123456\n'); + // Then the input consumes it as digits, and a detached field no longer consumes paste + expect(event.defaultPrevented).toBe(true); + expect(onChangeText).toHaveBeenLastCalledWith('123456'); + unmount(); + onChangeText.mockClear(); + expect(pasteCode('654321').defaultPrevented).toBe(false); + expect(onChangeText).not.toHaveBeenCalled(); + }); + + it.each(['unfocused', 'absent', 'no-focus-method'])('ignores paste for an %s ref', (condition) => { + // Given no evidence that the backing input currently owns focus + mockFocused = false; + if (condition === 'absent') { + mockInput = null; + } else if (condition === 'no-focus-method') { + const inputMethods: Pick = document.createElement('input'); + mockInput = createMock(inputMethods); + } + const onChangeText = jest.fn>, Parameters>>(); + render( + , + ); + // When a numeric document paste occurs + const event = pasteCode('123456'); + // Then the production focus helper prevents the field from consuming another field's paste + expect(event.defaultPrevented).toBe(false); + expect(onChangeText).not.toHaveBeenCalled(); + }); + + it.each(['abc', ' ', '12x34'])('rejects focused invalid paste %s', (text) => { + // Given a focused code input + const onChangeText = jest.fn>, Parameters>>(); + render( + , + ); + // When clipboard content is not a numeric code + const event = pasteCode(text); + // Then default paste is consumed without introducing invalid digits + expect(event.defaultPrevented).toBe(true); + expect(onChangeText).not.toHaveBeenCalled(); + }); + + it('uses only outer Shift+Tab and leaves boundary Tab navigation to the browser', () => { + // Given the first of three code slots + const onChangeText = jest.fn>, Parameters>>(); + render( + , + ); + // When Shift+Tab would leave the input, and a native-only shift flag accompanies forward Tab + expect(pressCodeKey('Tab', true)).not.toHaveBeenCalled(); + expect(pressCodeKey('Tab', undefined, true)).toHaveBeenCalledTimes(1); + // Then typing goes into slot two because native shiftKey is not an outer browser modifier + act(() => getCodeInputProps().onChangeText?.('7')); + expect(onChangeText).toHaveBeenLastCalledWith(' 7 '); + expect(pressCodeKey('Tab')).not.toHaveBeenCalled(); + expect(pressCodeKey('Tab', true)).toHaveBeenCalledTimes(1); + act(() => getCodeInputProps().onChangeText?.('8')); + expect(onChangeText).toHaveBeenLastCalledWith(' 8 '); + }); + + it('retains arrow movement and backspace replacement at the focused slot', () => { + // Given an existing three-digit code that should remain editable + const onChangeText = jest.fn>, Parameters>>(); + render( + , + ); + // When arrows reach the middle slot and backspace deletes that digit + pressCodeKey('ArrowRight'); + pressCodeKey('ArrowRight'); + pressCodeKey('ArrowLeft'); + pressCodeKey('Backspace'); + // Then surrounding digits remain in their original slots + expect(onChangeText).toHaveBeenLastCalledWith('1 3'); + }); + + it('accepts the native digit-pad producer and retains imperative editing handles', () => { + // Given a disabled keyboard driven by lastPressedDigit + const onChangeText = jest.fn>, Parameters>>(); + const ref = createRef(); + const {rerender} = render( + , + ); + // When the native pad sends its backspace key and the caller resets the input + rerender( + , + ); + expect(onChangeText).toHaveBeenCalledWith(''); + expect(ref.current).not.toBeNull(); + act(() => ref.current?.focus()); + act(() => ref.current?.focusLastSelected()); + act(() => ref.current?.resetFocus()); + act(() => ref.current?.blur()); + act(() => ref.current?.clear()); + // Then the public handle still clears through the real callback + expect(onChangeText).toHaveBeenLastCalledWith(''); + }); + + it('blocks offline Enter and submits the current value after reconnect', () => { + // Given an offline field whose code is not submitted automatically + mockOffline = true; + const onFulfill = jest.fn>, Parameters>>(); + const {rerender} = render( + , + ); + // When Enter is pressed offline and again after reconnect + pressCodeKey('Enter'); + expect(onFulfill).not.toHaveBeenCalled(); + mockOffline = false; + rerender( + , + ); + pressCodeKey('Enter'); + // Then only the online attempt reaches the submission callback + expect(onFulfill).toHaveBeenCalledTimes(1); + expect(onFulfill).toHaveBeenCalledWith('123'); + }); + + it('submits a completed code once and blurs the backing input', () => { + // Given an incomplete code and a real backing focus handle + const onFulfill = jest.fn>, Parameters>>(); + const blur = mockInput ? jest.spyOn(mockInput, 'blur') : undefined; + const {rerender} = render( + , + ); + // When the final digit arrives + rerender( + , + ); + // Then completion submits exactly once and releases focus + expect(onFulfill).toHaveBeenCalledTimes(1); + expect(onFulfill).toHaveBeenCalledWith('123'); + expect(blur).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/DomUtilsWebTest.ts b/tests/unit/DomUtilsWebTest.ts new file mode 100644 index 000000000000..6bf8417f575c --- /dev/null +++ b/tests/unit/DomUtilsWebTest.ts @@ -0,0 +1,68 @@ +/** Exercises web CSS insertion against real DOM nodes, including the supported legacy stylesheet contribution. */ +import type * as WebDomUtils from '@libs/DomUtils'; + +const {default: DomUtils} = jest.requireActual('../../src/libs/DomUtils/index.ts'); + +describe('web DomUtils', () => { + afterEach(() => { + for (const node of document.head.querySelectorAll('[id^="dom-utils-test"]')) { + node.remove(); + } + jest.restoreAllMocks(); + }); + + it('creates and updates one modern style node for a repeated ID', () => { + // Given a style ID not yet present in the document + const id = 'dom-utils-test-modern'; + // When two theme updates target that ID + DomUtils.addCSS('input {color: red;}', id); + const style = document.getElementById(id); + expect(style).toBeInstanceOf(HTMLStyleElement); + expect(style?.getAttribute('type')).toBe('text/css'); + expect(style?.textContent).toBe('input {color: red;}'); + DomUtils.addCSS('input {color: blue;}', id); + // Then the existing node holds the replacement CSS rather than accumulating duplicates + expect(document.getElementById(id)).toBe(style); + expect(document.querySelectorAll(`#${id}`)).toHaveLength(1); + expect(style?.textContent).toBe('input {color: blue;}'); + }); + + it.each([false, true])('writes the legacy cssText contribution with an existing node: %s', (existing) => { + // Given the styleSheet contribution exposed by the supported legacy browser branch + const style = document.createElement('style'); + style.id = 'dom-utils-test-legacy'; + const styleSheet = {cssText: ''}; + Object.defineProperty(style, 'styleSheet', {value: styleSheet}); + if (existing) { + document.head.appendChild(style); + } else { + jest.spyOn(document, 'createElement').mockReturnValueOnce(style); + } + // When CSS is inserted and replaced through the real addCSS implementation + DomUtils.addCSS('textarea {color: red;}', style.id); + expect(styleSheet.cssText).toBe('textarea {color: red;}'); + DomUtils.addCSS('textarea {color: blue;}', style.id); + // Then both legacy branches preserve node identity and write only the stylesheet + expect(document.getElementById(style.id)).toBe(style); + expect(styleSheet.cssText).toBe('textarea {color: blue;}'); + expect(style.childNodes).toHaveLength(0); + }); + + it.each(['', '.signin'])('generates every autofill selector and the exact color declarations for %s', (scope) => { + // Given the App or sign-in selector and the theme text color + const color = '#123456'; + // When the production generator builds CSS + const css = DomUtils.getAutofilledInputStyle(color, scope); + // Then every supported control/state shares the requested text and caret colors + const selectors = css + .slice(0, css.indexOf('{')) + .split(',') + .map((selector) => selector.trim().replaceAll(/\s+/g, ' ')); + expect(selectors).toEqual( + ['[chrome-autofilled]', ':-webkit-autofill'].flatMap((suffix) => + ['input', 'textarea', 'select'].flatMap((control) => ['', ':hover', ':focus'].map((state) => `${scope} ${control}${suffix}${state}`.trim())), + ), + ); + expect(css.slice(css.indexOf('{')).replaceAll(/\s+/g, ' ').trim()).toBe(`{ -webkit-background-clip: text; -webkit-text-fill-color: ${color}; caret-color: ${color}; }`); + }); +}); diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index f94025165124..3be1859529bf 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -69,6 +69,7 @@ import type {Connections} from '@src/types/onyx/Policy'; // we need "dirty" object key names in these tests import type {NavigationState} from '@react-navigation/native'; import type {OnyxCollection} from 'react-native-onyx'; +import type {IsEqual} from 'type-fest'; import createMock from 'tests/utils/createMock'; @@ -5542,6 +5543,43 @@ describe('SearchQueryUtils', () => { }); }); + describe('getAllPolicyValues category projections', () => { + test('keeps projected names separate from complete category and policy results', () => { + // Given complete category models and the intentional name-only projection + const fullCategories: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}1`]: {Food: {name: 'Food', enabled: false}}, + }; + const names: OnyxCollection>> = { + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}1`]: {foodKey: {name: 'Food'}}, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}2`]: {travelKey: {name: 'Travel'}}, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}3`]: undefined, + }; + // When the existing exported interface selects each category of input + const full = getAllPolicyValues(undefined, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, fullCategories); + const projected = getAllPolicyValues({value: ['1'], isNegated: false}, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, names); + const negated = getAllPolicyValues({value: ['1'], isNegated: true}, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, names); + const all = getAllPolicyValues(undefined, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, names); + const policies = getAllPolicyValues(undefined, ONYXKEYS.COLLECTION.POLICY, {[`${ONYXKEYS.COLLECTION.POLICY}1`]: createRandomPolicy(1)}); + // Then old callers retain their exact full-model result, while the projection promises only names + const retainsCategories: IsEqual = true; + const retainsPolicies: IsEqual = true; + const retainsProjection: IsEqual>>> = true; + expect([retainsCategories, retainsPolicies, retainsProjection]).toEqual([true, true, true]); + expect(full.at(0)?.Food?.enabled).toBe(false); + expect(policies.at(0)?.id).toBe('1'); + expect(projected).toEqual([{foodKey: {name: 'Food'}}]); + expect(projected.at(0)).toBe(names[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}1`]); + expect(negated).toEqual([{travelKey: {name: 'Travel'}}]); + expect(all).toEqual([...projected, ...negated]); + // @ts-expect-error -- name projections do not promise the omitted enabled metadata. + expect(projected.at(0)?.foodKey?.enabled).toBeUndefined(); + // @ts-expect-error -- the policy key must reject name-only category dictionaries. + getAllPolicyValues(undefined, ONYXKEYS.COLLECTION.POLICY, names); + // @ts-expect-error -- the category key must reject full Policy items. + getAllPolicyValues(undefined, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {[`${ONYXKEYS.COLLECTION.POLICY}1`]: createRandomPolicy(1)}); + }); + }); + describe('getAllPolicyValuesMap', () => { const policy1 = createRandomPolicy(1, undefined, 'Workspace 1'); const policy2 = createRandomPolicy(2, undefined, 'Workspace 2'); diff --git a/tests/unit/components/Avatar/connected/InvoiceRoomAvatarTest.tsx b/tests/unit/components/Avatar/connected/InvoiceRoomAvatarTest.tsx index 5dc59e71b365..5e798363f158 100644 --- a/tests/unit/components/Avatar/connected/InvoiceRoomAvatarTest.tsx +++ b/tests/unit/components/Avatar/connected/InvoiceRoomAvatarTest.tsx @@ -75,7 +75,7 @@ jest.mock('@hooks/useLocalize', () => })), ); -let mockPersonalDetails: PersonalDetailsList = {}; +let mockPersonalDetails: PersonalDetailsList | undefined = {}; /** Renders inside the live personal details context, which the component reads while there is no Search snapshot. */ const renderWithPersonalDetails = (ui: React.ReactElement) => render({ui}); @@ -371,4 +371,25 @@ describe('InvoiceRoomAvatar (connected)', () => { // Then the subscript gets the default avatar seeded from the workspace name expect(mockCapturedSubscriptAvatarProps.secondaryAvatar).toEqual({...RECEIVER_WORKSPACE_ICON, source: getDefaultWorkspaceAvatar(RECEIVER_POLICY_NAME)}); }); + it.each([false, true])('keeps the person fallback when live details are unavailable, outside provider: %s', (outside) => { + // Given the receiver exists but its live personal details have not arrived + mockPersonalDetails = undefined; + const avatar = ( + + ); + // When the real receiver resolver sees the raw null or undefined context value + if (outside) { + render(avatar); + } else { + renderWithPersonalDetails(avatar); + } + // Then both states retain the visible sender-only fallback layout + expect(screen.getByTestId('MockedSingleAvatar')).toBeOnTheScreen(); + expect(mockCapturedSingleAvatarProps.avatar).toBe(SENDER_ICON); + expect(mockCapturedSingleAvatarProps.size).toBe(CONST.AVATAR_SIZE.DEFAULT); + }); }); diff --git a/tests/unit/components/Avatar/connected/useSortedIconsTest.ts b/tests/unit/components/Avatar/connected/useSortedIconsTest.ts index a1e382182406..b62f0297f6a4 100644 --- a/tests/unit/components/Avatar/connected/useSortedIconsTest.ts +++ b/tests/unit/components/Avatar/connected/useSortedIconsTest.ts @@ -1,4 +1,4 @@ -import {renderHook} from '@testing-library/react-native'; +import {act, renderHook} from '@testing-library/react-native'; import useSortedIcons from '@components/Avatar/connected/useSortedIcons'; import {PersonalDetailsContext} from '@components/OnyxListItemProvider'; @@ -7,9 +7,9 @@ import CONST from '@src/CONST'; import type {PersonalDetailsList} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; -import type {PropsWithChildren} from 'react'; +import type {Dispatch, PropsWithChildren, SetStateAction} from 'react'; -import React from 'react'; +import React, {useEffect, useState} from 'react'; const ZOE_ACCOUNT_ID = 42; const ADAM_ACCOUNT_ID = 7; @@ -25,7 +25,7 @@ const UNNAMED_ZOE_ICON: Icon = {id: ZOE_ACCOUNT_ID, type: CONST.ICON_TYPE_AVATAR const ICONS = [ZOE_ICON, ADAM_ICON, WORKSPACE_ICON]; -let mockPersonalDetails: PersonalDetailsList = {}; +let mockPersonalDetails: PersonalDetailsList | undefined = {}; jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ @@ -78,4 +78,46 @@ describe('useSortedIcons', () => { // Then the icon sorts by the personal-details name rather than its empty name expect(result.current).toEqual([ADAM_ICON, UNNAMED_ZOE_ICON]); }); + it('sorts with the same fallback when the provider is absent or its value is undefined', () => { + // Given an unnamed account icon with no personal details available + const icons = [ADAM_ICON, UNNAMED_ZOE_ICON]; + mockPersonalDetails = undefined; + // When the actual name sorter reads null outside the provider or undefined inside it + const outside = renderHook(() => useSortedIcons(icons, CONST.REPORT_ACTION_AVATARS.SORT_BY.NAME)); + const inside = renderHook(() => useSortedIcons(icons, CONST.REPORT_ACTION_AVATARS.SORT_BY.NAME), {wrapper: PersonalDetailsWrapper}); + // Then the missing-name icon sorts first and neither call mutates the source + expect(outside.result.current).toEqual([UNNAMED_ZOE_ICON, ADAM_ICON]); + expect(inside.result.current).toEqual(outside.result.current); + expect(icons).toEqual([ADAM_ICON, UNNAMED_ZOE_ICON]); + }); + + it('subscribes to personal details only for a name sort', () => { + // Given ID sorting, which does not depend on personal details + let updateDetails: Dispatch> = jest.fn]>(); + const observeDetails = jest.fn(); + function UpdatingDetailsWrapper({children}: PropsWithChildren) { + const [details, setDetails] = useState({}); + useEffect(() => { + updateDetails = setDetails; + observeDetails(details); + }, [details]); + return React.createElement(PersonalDetailsContext.Provider, {value: details}, children); + } + let renders = 0; + const {result} = renderHook( + () => { + renders += 1; + return useSortedIcons(ICONS, CONST.REPORT_ACTION_AVATARS.SORT_BY.ID); + }, + {wrapper: UpdatingDetailsWrapper}, + ); + const before = renders; + // When only context details change without rerendering the hook's parent + const updatedDetails: PersonalDetailsList = {[ZOE_ACCOUNT_ID]: {accountID: ZOE_ACCOUNT_ID, displayName: 'Aaron'}}; + act(() => updateDetails(updatedDetails)); + // Then no context subscription causes a render for an ID-only sort + expect(observeDetails).toHaveBeenLastCalledWith(updatedDetails); + expect(renders).toBe(before); + expect(result.current).toEqual([ADAM_ICON, ZOE_ICON, WORKSPACE_ICON]); + }); }); diff --git a/tests/unit/createOnyxContextTest.tsx b/tests/unit/createOnyxContextTest.tsx new file mode 100644 index 000000000000..66fb99421519 --- /dev/null +++ b/tests/unit/createOnyxContextTest.tsx @@ -0,0 +1,98 @@ +/** Protects key-specific Onyx values and the factory's independent outside-provider marker. */ +import {act, renderHook} from '@testing-library/react-native'; + +import createOnyxContext from '@components/createOnyxContext'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {PropsWithChildren} from 'react'; +import type {OnyxValue, UseOnyxResult} from 'react-native-onyx'; +import type {IsEqual} from 'type-fest'; + +import React, {useContext} from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const [SessionProvider, SessionContext, useSession] = createOnyxContext(ONYXKEYS.SESSION); +const [BetasProvider, , useBetas] = createOnyxContext(ONYXKEYS.BETAS); +const [CardsProvider, CardsContext, useCards] = createOnyxContext(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); + +function SessionWrapper({children}: PropsWithChildren) { + return {children}; +} + +function CardsWrapper({children}: PropsWithChildren) { + return {children}; +} + +describe('createOnyxContext', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + beforeEach(async () => { + await act(async () => Onyx.clear()); + }); + + it('exposes null outside a provider and throws the exact key-bearing hook error', () => { + // Given no provider for the session key + // When the raw context and the strict hook are read + const {result} = renderHook(() => useContext(SessionContext)); + // Then only the strict hook rejects the outside-provider marker + expect(result.current).toBeNull(); + expect(() => renderHook(useSession)).toThrow(`useOnyxContext must be used within a OnyxListItemProvider [key: ${ONYXKEYS.SESSION}]`); + expect(SessionProvider.displayName).toBe('SessionProvider'); + }); + + it('allows undefined inside the provider, then hydrates and updates the single-key value', async () => { + // Given a provider whose value has not loaded + const {result} = renderHook(useSession, {wrapper: SessionWrapper}); + await waitForBatchedUpdatesWithAct(); + expect(result.current).toBeUndefined(); + // When the actual Onyx subscription receives a session and a subsequent update + await act(async () => Onyx.set(ONYXKEYS.SESSION, {accountID: 42, email: 'first@example.com'})); + await waitForBatchedUpdatesWithAct(); + expect(result.current).toEqual({accountID: 42, email: 'first@example.com'}); + await act(async () => Onyx.merge(ONYXKEYS.SESSION, {email: 'second@example.com'})); + await waitForBatchedUpdatesWithAct(); + // Then the hook keeps the producer's session fields through the update + expect(result.current).toEqual({accountID: 42, email: 'second@example.com'}); + const session: UseOnyxResult>[0] = result.current; + expect(session?.accountID).toBe(42); + }); + + it('retains beta arrays and keyed workspace-card collections as distinct result types', async () => { + // Given the two actual key categories supported by the factory + const cards = {[`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}1`]: {}}; + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, []); + await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}1`, {}); + }); + // When providers read the values + const betasHook = renderHook(useBetas, {wrapper: BetasProvider}); + const cardsHook = renderHook(useCards, {wrapper: CardsWrapper}); + await waitForBatchedUpdatesWithAct(); + const betas: UseOnyxResult>[0] = betasHook.result.current; + const cardCollection: UseOnyxResult>[0] = cardsHook.result.current; + // Then arrays retain array methods while collections retain their keyed shape + expect(betas?.includes('all')).toBe(false); + expect(cardCollection).toEqual(cards); + }); + it('preserves undefined collection members and exact key-specific hook contracts', () => { + // Given the declared collection result can include a member that has not loaded + const cards: UseOnyxResult>[0] = { + [`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}1`]: {}, + [`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}2`]: undefined, + }; + function PartialCollectionWrapper({children}: PropsWithChildren) { + return {children}; + } + // When the generated hook reads that valid producer-shaped context value + const {result} = renderHook(useCards, {wrapper: PartialCollectionWrapper}); + // Then no reconstruction drops the undefined member and no key/model correlation is lost + expect(result.current).toBe(cards); + expect(result.current).toHaveProperty(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}2`, undefined); + const sessionType: IsEqual, UseOnyxResult>[0]> = true; + const betasType: IsEqual, UseOnyxResult>[0]> = true; + const cardsType: IsEqual, UseOnyxResult>[0]> = true; + expect([sessionType, betasType, cardsType]).toEqual([true, true, true]); + }); +}); diff --git a/tests/unit/hooks/useFilterFormValues.test.ts b/tests/unit/hooks/useFilterFormValues.test.ts index 01c55af324b6..42a866a181e5 100644 --- a/tests/unit/hooks/useFilterFormValues.test.ts +++ b/tests/unit/hooks/useFilterFormValues.test.ts @@ -1,20 +1,29 @@ +import {act, renderHook} from '@testing-library/react-native'; + import {typeOptionsPoliciesSelector} from '@components/Search/FilterComponents/TypeSelector'; import {advancedSearchPoliciesSelector} from '@hooks/useAdvancedSearchFilters'; import {exportedToPoliciesSelector} from '@hooks/useExportedToFilterOptions'; import {policiesSelector, policyCategoriesSelector} from '@hooks/useFilterFormValues'; +import useOnyx from '@hooks/useOnyx'; + +import {getAllTaxRates} from '@libs/PolicyUtils'; +import {buildFilterFormValuesFromQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyReportField} from '@src/types/onyx'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxKey} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; import createMock from '../../utils/createMock'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; -const POLICY_KEY = `${ONYXKEYS.COLLECTION.POLICY}1`; -const POLICY_KEY_2 = `${ONYXKEYS.COLLECTION.POLICY}2`; -const CATEGORIES_KEY = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}1`; -const CATEGORIES_KEY_2 = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}2`; +const POLICY_KEY = `${ONYXKEYS.COLLECTION.POLICY}1` satisfies OnyxKey; +const POLICY_KEY_2 = `${ONYXKEYS.COLLECTION.POLICY}2` satisfies OnyxKey; +const CATEGORIES_KEY = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}1` satisfies OnyxKey; +const CATEGORIES_KEY_2 = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}2` satisfies OnyxKey; describe('useFilterFormValues selectors', () => { describe('policiesSelector', () => { @@ -269,4 +278,90 @@ describe('useFilterFormValues selectors', () => { expect(result).not.toHaveProperty(POLICY_KEY_2); }); }); + describe('projection consumers', () => { + it('passes exact tax and category projections through the real filter builder', () => { + // Given two policies with overlapping tax keys and disabled categories whose names remain searchable + const policies: OnyxCollection = { + [POLICY_KEY]: createMock({id: '1', name: 'One', taxRates: {taxes: {vat: {name: 'VAT', value: '20%'}, local: {name: 'Local', value: '5%'}}}}), + [POLICY_KEY_2]: createMock({id: '2', name: 'Two', taxRates: {taxes: {vat: {name: 'VAT', value: '10%'}, other: {name: 'VAT', value: '15%'}}}}), + [`${ONYXKEYS.COLLECTION.POLICY}3`]: undefined, + }; + const categories: OnyxCollection = { + [CATEGORIES_KEY]: {foodKey: {name: 'Food', enabled: false}}, + [CATEGORIES_KEY_2]: {travelKey: {name: 'Travel', enabled: true}}, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}3`]: undefined, + }; + const projectedPolicies = policiesSelector(policies); + const projectedCategories = policyCategoriesSelector(categories); + const taxes = getAllTaxRates(projectedPolicies); + // When each scoped query is restored from the reduced subscriptions + const positive = buildSearchQueryJSON('category:Food,Travel tax-rate:vat,other,missing policyID:1'); + const negative = buildSearchQueryJSON('category:Food,Travel -policyID:1'); + expect(positive).toBeDefined(); + expect(negative).toBeDefined(); + if (!positive || !negative) { + throw new Error('The production parser did not build the search fixtures'); + } + const form = buildFilterFormValuesFromQuery(positive, projectedCategories, {}, undefined, undefined, undefined, taxes); + const negatedForm = buildFilterFormValuesFromQuery(negative, projectedCategories, {}, undefined, undefined, undefined, taxes); + // Then key/name projection, deduplication and policy scoping survive without invented metadata + expect(projectedPolicies?.[POLICY_KEY]).toEqual({taxRates: policies[POLICY_KEY]?.taxRates}); + expect(projectedCategories).toEqual({[CATEGORIES_KEY]: {foodKey: {name: 'Food'}}, [CATEGORIES_KEY_2]: {travelKey: {name: 'Travel'}}}); + expect(taxes).toEqual({VAT: ['vat', 'other'], Local: ['local']}); + expect(form.category).toEqual(['Food']); + expect(form.taxRate).toEqual(['vat', 'other']); + expect(negatedForm.category).toEqual(['Travel']); + expect(policiesSelector({})).toEqual({}); + expect(policyCategoriesSelector({})).toEqual({}); + expect(getAllTaxRates(undefined)).toEqual({}); + }); + + it('does not notify projected subscriptions for unrelated metadata but does for consumed values', async () => { + // Given real Onyx subscriptions to the two production selectors + Onyx.init({keys: ONYXKEYS}); + await act(async () => { + await Onyx.clear(); + await Onyx.set(POLICY_KEY, createMock({id: '1', name: 'One', taxRates: {taxes: {vat: {name: 'VAT', value: '20%'}}}})); + await Onyx.set(CATEGORIES_KEY, {foodKey: {name: 'Food', enabled: true}}); + }); + let policyRenders = 0; + let categoryRenders = 0; + const policyHook = renderHook(() => { + policyRenders += 1; + return useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policiesSelector})[0]; + }); + const categoryHook = renderHook(() => { + categoryRenders += 1; + return useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {selector: policyCategoriesSelector})[0]; + }); + await waitForBatchedUpdatesWithAct(); + const policiesBefore = policyHook.result.current; + const categoriesBefore = categoryHook.result.current; + const policyRendersBefore = policyRenders; + const categoryRendersBefore = categoryRenders; + // When only omitted policy/category metadata changes + await act(async () => { + await Onyx.merge(POLICY_KEY, {name: 'Renamed'}); + await Onyx.merge(CATEGORIES_KEY, {foodKey: {enabled: false}}); + }); + await waitForBatchedUpdatesWithAct(); + // Then the actual subscriptions retain their selected values without rendering + expect(policyHook.result.current).toBe(policiesBefore); + expect(categoryHook.result.current).toBe(categoriesBefore); + expect(policyRenders).toBe(policyRendersBefore); + expect(categoryRenders).toBe(categoryRendersBefore); + await act(async () => { + await Onyx.merge(POLICY_KEY, {taxRates: {taxes: {vat: {name: 'New VAT'}}}}); + await Onyx.merge(CATEGORIES_KEY, {foodKey: {name: 'Dining'}}); + }); + await waitForBatchedUpdatesWithAct(); + expect(getAllTaxRates(policyHook.result.current)).toEqual(Object.fromEntries([['New VAT', ['vat']]])); + expect(categoryHook.result.current?.[CATEGORIES_KEY]).toEqual({foodKey: {name: 'Dining'}}); + expect(policyRenders).toBeGreaterThan(policyRendersBefore); + expect(categoryRenders).toBeGreaterThan(categoryRendersBefore); + policyHook.unmount(); + categoryHook.unmount(); + await act(async () => Onyx.clear()); + }); + }); }); diff --git a/tests/unit/usePermissionsTest.tsx b/tests/unit/usePermissionsTest.tsx index 99069e3b8b57..7480d47419d9 100644 --- a/tests/unit/usePermissionsTest.tsx +++ b/tests/unit/usePermissionsTest.tsx @@ -1,6 +1,6 @@ import {renderHook} from '@testing-library/react-native'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import OnyxListItemProvider, {BetasContext, BetaConfigurationContext, BetaOverridesContext} from '@components/OnyxListItemProvider'; import Permissions from '@libs/Permissions'; @@ -189,6 +189,26 @@ describe('usePermissions', () => { expect(result.current.isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL)).toBe(true); }); + it('treats absent-provider null as off while preserving undefined as unknown', () => { + // Given the raw contexts have no providers + const outside = renderHook(() => usePermissions()); + // When a beta is checked without loaded account state + // Then null means outside-provider off rather than the inside-provider loading state + expect(outside.result.current.isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)).toBe(false); + expect(outside.result.current.isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING)).toBe(false); + const inside = renderHook(() => usePermissions(), { + wrapper: ({children}: ChildrenProps) => ( + + + {children} + + + ), + }); + expect(inside.result.current.isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)).toBe(false); + expect(inside.result.current.isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING)).toBeUndefined(); + }); + describe('isBetaEnabledOrUnknown', () => { it('should return undefined while the account betas have not loaded yet', async () => { // Given: An account whose betas have not arrived from the server yet From d572d8edb81ae2d82ae976eb9b4b52229557ad7d Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:47:04 +0000 Subject: [PATCH 2/3] Make input test mocks compatible with React Compiler Seatbelt-Operation: add2564d-f961-4201-85ce-d47084230597 --- tests/ui/TextInputWebRefAndFocusTest.tsx | 8 +++++--- tests/ui/ValidateCodeInputEventTest.tsx | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/ui/TextInputWebRefAndFocusTest.tsx b/tests/ui/TextInputWebRefAndFocusTest.tsx index 095be4a589e4..6fb87537ff47 100644 --- a/tests/ui/TextInputWebRefAndFocusTest.tsx +++ b/tests/ui/TextInputWebRefAndFocusTest.tsx @@ -44,15 +44,17 @@ jest.mock('@hooks/useThemeStyles', () => () => ({ jest.mock('@components/TextInput/BaseTextInput', () => { const ReactMock = jest.requireActual('react'); function MockBaseTextInput(props: BaseTextInputProps) { - mockCaptureInputProps(props); + const {ref: inputRef} = props; + ReactMock.useLayoutEffect(() => { + mockCaptureInputProps(props); + }); ReactMock.useLayoutEffect(() => { - const inputRef = props.ref; if (typeof inputRef === 'function') { inputRef(mockInputRef); return () => inputRef(null); } return undefined; - }, [props.ref]); + }, [inputRef]); return null; } return {__esModule: true, default: MockBaseTextInput}; diff --git a/tests/ui/ValidateCodeInputEventTest.tsx b/tests/ui/ValidateCodeInputEventTest.tsx index 42b4c3994a3b..17aeee1caf0b 100644 --- a/tests/ui/ValidateCodeInputEventTest.tsx +++ b/tests/ui/ValidateCodeInputEventTest.tsx @@ -25,15 +25,17 @@ jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})) jest.mock('@components/TextInput', () => { const ReactMock = jest.requireActual('react'); function MockCodeInput(props: BaseTextInputProps) { - mockCaptureCodeInputProps(props); + const {ref: inputRef} = props; + ReactMock.useLayoutEffect(() => { + mockCaptureCodeInputProps(props); + }); ReactMock.useLayoutEffect(() => { - const inputRef = props.ref; if (typeof inputRef === 'function') { inputRef(mockInput); return () => inputRef(null); } return undefined; - }, [props.ref]); + }, [inputRef]); return null; } return {__esModule: true, default: MockCodeInput}; From 6ea7022d8baf1b32f7f90cc08cf8ecc4721ced27 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:27:24 +0000 Subject: [PATCH 3/3] Address shared input and Search review feedback Restore baseline Shift modifier access with a typed web modifier. Seatbelt-Operation: reviewer-shift-rollback-sign-104082-49f03569-71bc-4ad9-88a4-d61534f2a9f9 --- src/components/ValidateCodeInput.tsx | 6 ++-- src/libs/SearchQueryUtils.ts | 36 +++++------------------ tests/ui/ValidateCodeInputEventTest.tsx | 2 ++ tests/unit/Search/SearchQueryUtilsTest.ts | 26 +++------------- tests/unit/createOnyxContextTest.tsx | 9 ++---- 5 files changed, 19 insertions(+), 60 deletions(-) diff --git a/src/components/ValidateCodeInput.tsx b/src/components/ValidateCodeInput.tsx index 0d0095ffbbf6..171755f12fab 100644 --- a/src/components/ValidateCodeInput.tsx +++ b/src/components/ValidateCodeInput.tsx @@ -9,7 +9,7 @@ import {isNumeric} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; -import type {ForwardedRef} from 'react'; +import type {ForwardedRef, KeyboardEvent} from 'react'; import type {FocusEvent, TextInputKeyPressEvent} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -344,7 +344,7 @@ function ValidateCodeInput({ * NOTE: when using Android Emulator, this can only be tested using * hardware keyboard inputs. */ - const onKeyPress = (event: Partial) => { + const onKeyPress = (event: Partial & Partial>) => { const keyValue = event?.nativeEvent?.key; if (keyValue === 'Backspace' || keyValue === '<') { let numbers = decomposeString(value, maxLength); @@ -415,7 +415,7 @@ function ValidateCodeInput({ setInput(TEXT_INPUT_EMPTY_STATE); onFulfill(value); } else if (keyValue === 'Tab' && focusedIndex !== undefined) { - const newFocusedIndex = 'shiftKey' in event && event.shiftKey ? focusedIndex - 1 : focusedIndex + 1; + const newFocusedIndex = event.shiftKey ? focusedIndex - 1 : focusedIndex + 1; if (newFocusedIndex >= 0 && newFocusedIndex < maxLength) { setInputAndIndex(newFocusedIndex); inputRef.current?.focus(); diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 8bb5fe05f489..fc6f87a05291 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -1578,24 +1578,7 @@ function isPolicyCollection(key: OnyxCollectionKey, data: unknown): data is Onyx return !!data; } -function getAllPolicyValues( - policyID: Filter | undefined, - key: T, - policyData: OnyxCollection, - policies?: OnyxCollection, -): Array; -function getAllPolicyValues( - policyID: Filter | undefined, - key: typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES, - policyData: OnyxCollection>>, - policies?: OnyxCollection, -): Array>>; -function getAllPolicyValues( - policyID: Filter | undefined, - key: T, - policyData: OnyxCollection>>, - policies?: OnyxCollection, -): Array>> { +function getAllPolicyValues(policyID: Filter | undefined, key: OnyxCollectionKey, policyData: OnyxCollection, policies?: OnyxCollection): TValue[] { if (!policyData || !policyID || !policyID.value) { return Object.values(policyData ?? {}).filter((data): data is NonNullable => !!data); } @@ -1604,16 +1587,13 @@ function getAllPolicyValues( const resolvedValues = policiesForResolution ? policyID.value.map((id) => resolvePolicyIDFromName(id, policiesForResolution)) : policyID.value; if (policyID.isNegated) { - return Object.keys(policyData).reduce( - (acc, curr) => { - const id = curr.replace(key, ''); - if (!resolvedValues.includes(id) && policyData[curr]) { - acc.push(policyData[curr]); - } - return acc; - }, - [] as Array>>, - ); + return Object.keys(policyData).reduce((acc, curr) => { + const id = curr.replace(key, ''); + if (!resolvedValues.includes(id) && policyData[curr]) { + acc.push(policyData[curr]); + } + return acc; + }, []); } return resolvedValues.map((id) => policyData?.[`${key}${id}`]).filter((data): data is NonNullable => !!data); diff --git a/tests/ui/ValidateCodeInputEventTest.tsx b/tests/ui/ValidateCodeInputEventTest.tsx index 17aeee1caf0b..7c4b00ce56fd 100644 --- a/tests/ui/ValidateCodeInputEventTest.tsx +++ b/tests/ui/ValidateCodeInputEventTest.tsx @@ -213,9 +213,11 @@ describe('ValidateCodeInput production events', () => { act(() => ref.current?.focusLastSelected()); act(() => ref.current?.resetFocus()); act(() => ref.current?.blur()); + onChangeText.mockClear(); act(() => ref.current?.clear()); // Then the public handle still clears through the real callback expect(onChangeText).toHaveBeenLastCalledWith(''); + expect(onChangeText).toHaveBeenCalledTimes(1); }); it('blocks offline Enter and submits the current value after reconnect', () => { diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 3be1859529bf..b2b57344c92f 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -69,7 +69,6 @@ import type {Connections} from '@src/types/onyx/Policy'; // we need "dirty" object key names in these tests import type {NavigationState} from '@react-navigation/native'; import type {OnyxCollection} from 'react-native-onyx'; -import type {IsEqual} from 'type-fest'; import createMock from 'tests/utils/createMock'; @@ -5544,39 +5543,22 @@ describe('SearchQueryUtils', () => { }); describe('getAllPolicyValues category projections', () => { - test('keeps projected names separate from complete category and policy results', () => { - // Given complete category models and the intentional name-only projection - const fullCategories: OnyxCollection = { - [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}1`]: {Food: {name: 'Food', enabled: false}}, - }; + test('selects projected names and skips undefined collection members', () => { + // Given category selectors retain only names and collection members may not have loaded const names: OnyxCollection>> = { [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}1`]: {foodKey: {name: 'Food'}}, [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}2`]: {travelKey: {name: 'Travel'}}, [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}3`]: undefined, }; - // When the existing exported interface selects each category of input - const full = getAllPolicyValues(undefined, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, fullCategories); + // When workspace filters must work without complete category metadata const projected = getAllPolicyValues({value: ['1'], isNegated: false}, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, names); const negated = getAllPolicyValues({value: ['1'], isNegated: true}, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, names); const all = getAllPolicyValues(undefined, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, names); - const policies = getAllPolicyValues(undefined, ONYXKEYS.COLLECTION.POLICY, {[`${ONYXKEYS.COLLECTION.POLICY}1`]: createRandomPolicy(1)}); - // Then old callers retain their exact full-model result, while the projection promises only names - const retainsCategories: IsEqual = true; - const retainsPolicies: IsEqual = true; - const retainsProjection: IsEqual>>> = true; - expect([retainsCategories, retainsPolicies, retainsProjection]).toEqual([true, true, true]); - expect(full.at(0)?.Food?.enabled).toBe(false); - expect(policies.at(0)?.id).toBe('1'); + // Then filtering must skip unloaded members without reconstructing the selected projections expect(projected).toEqual([{foodKey: {name: 'Food'}}]); expect(projected.at(0)).toBe(names[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}1`]); expect(negated).toEqual([{travelKey: {name: 'Travel'}}]); expect(all).toEqual([...projected, ...negated]); - // @ts-expect-error -- name projections do not promise the omitted enabled metadata. - expect(projected.at(0)?.foodKey?.enabled).toBeUndefined(); - // @ts-expect-error -- the policy key must reject name-only category dictionaries. - getAllPolicyValues(undefined, ONYXKEYS.COLLECTION.POLICY, names); - // @ts-expect-error -- the category key must reject full Policy items. - getAllPolicyValues(undefined, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {[`${ONYXKEYS.COLLECTION.POLICY}1`]: createRandomPolicy(1)}); }); }); diff --git a/tests/unit/createOnyxContextTest.tsx b/tests/unit/createOnyxContextTest.tsx index 66fb99421519..a9d5d32e97cd 100644 --- a/tests/unit/createOnyxContextTest.tsx +++ b/tests/unit/createOnyxContextTest.tsx @@ -7,7 +7,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {PropsWithChildren} from 'react'; import type {OnyxValue, UseOnyxResult} from 'react-native-onyx'; -import type {IsEqual} from 'type-fest'; import React, {useContext} from 'react'; import Onyx from 'react-native-onyx'; @@ -76,7 +75,7 @@ describe('createOnyxContext', () => { expect(betas?.includes('all')).toBe(false); expect(cardCollection).toEqual(cards); }); - it('preserves undefined collection members and exact key-specific hook contracts', () => { + it('preserves undefined collection members and the original collection reference', () => { // Given the declared collection result can include a member that has not loaded const cards: UseOnyxResult>[0] = { [`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}1`]: {}, @@ -87,12 +86,8 @@ describe('createOnyxContext', () => { } // When the generated hook reads that valid producer-shaped context value const {result} = renderHook(useCards, {wrapper: PartialCollectionWrapper}); - // Then no reconstruction drops the undefined member and no key/model correlation is lost + // Then consumers receive the same collection even when a member has not loaded expect(result.current).toBe(cards); expect(result.current).toHaveProperty(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}2`, undefined); - const sessionType: IsEqual, UseOnyxResult>[0]> = true; - const betasType: IsEqual, UseOnyxResult>[0]> = true; - const cardsType: IsEqual, UseOnyxResult>[0]> = true; - expect([sessionType, betasType, cardsType]).toEqual([true, true, true]); }); });