Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSidePanelActions from '@hooks/useSidePanelActions';
import useSidePanelState from '@hooks/useSidePanelState';

import {asCustomHistory, isModalHistorySentinel} from '@libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtensionUtils';
import {isModalHistoryGuardEntry} from '@libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtensionUtils';
import Navigation from '@libs/Navigation/Navigation';
import navigationRef from '@libs/Navigation/navigationRef';

Expand All @@ -27,7 +27,7 @@ export default function useSyncSidePanelWithHistory() {
const {isExtraLargeScreenWidth} = useResponsiveLayout();
// Modals with `shouldHandleNavigationBack` push their own back-guard entry after the Side Panel's, so skip those:
// a modal opened from the panel must not read as the panel having been closed.
const lastHistoryEntry = useNavigationState((state) => asCustomHistory(state?.history)?.findLast((entry) => !isModalHistorySentinel(entry)));
const lastHistoryEntry = useNavigationState((state) => state?.history?.findLast((entry) => !isModalHistoryGuardEntry(entry)));
const previousLastHistoryEntry = usePrevious(lastHistoryEntry);

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@ import compoundParamsKey from '@libs/compoundParamsKey';
import type {PlatformStackNavigationState, PlatformStackRouterFactory, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types';
import type {GoBackAction, SetParamsAction} from '@libs/Navigation/types';
import {cancelPendingFocusRestore, notifyPushParamsBackward, notifyPushParamsForward} from '@libs/NavigationFocusReturn';
import {isRecord} from '@libs/ObjectUtils';

import CONST from '@src/CONST';

import type {NavigationRoute, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native';
import type {ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native';

import {CommonActions} from '@react-navigation/native';

import type {CustomHistoryEntry, PushParamsActionType, PushParamsRouterAction} from './types';
import type {PushParamsActionType, PushParamsRouterAction} from './types';

import {enhanceStateWithHistory} from './utils';

function preserveHistoryForRoutes(oldHistory: CustomHistoryEntry[], routes: Array<{key?: string}>): CustomHistoryEntry[] {
function preserveHistoryForRoutes(oldHistory: unknown[], routes: Array<{key?: string}>): unknown[] {
const remainingKeys = new Set(routes.map((r) => r.key));
return oldHistory.filter((entry) => typeof entry === 'string' || remainingKeys.has(entry.key));
return oldHistory.filter((entry) => !isRecord(entry) || typeof entry.key !== 'string' || remainingKeys.has(entry.key));
}

// noop=match at cursor; backward/forward=move cursor to match; ambiguous=same compound at cursor±1 (see ambiguous branch); unknown=target not in history.
Expand All @@ -25,7 +26,7 @@ type ResetOutcome = {type: 'noop'; cursor: number} | {type: 'backward'; cursor:
* Classifies a RESET's target against our PUSH_PARAMS history + cursor so the caller fires the right focus-return notification and updates the cursor.
* Without the 'noop' branch, `useNavigationResetOnLayoutChange`'s reflexive `reset(getState())` on window resize would be treated as a real navigation — cancelling any pending Esc-triggered focus restore so focus never returns to the trigger.
*/
function resolveCursorForReset(history: CustomHistoryEntry[], currentCursor: number, newFocused: {key: string; params: unknown}): ResetOutcome {
function resolveCursorForReset(history: unknown[], currentCursor: number, newFocused: {key: string; params: unknown}): ResetOutcome {
const inRange = currentCursor >= 0 && currentCursor < history.length;
// Snapped cursor drives direction inference only; adjacent probes are gated on inRange.
const cursor = inRange ? currentCursor : history.length - 1;
Expand All @@ -36,7 +37,7 @@ function resolveCursorForReset(history: CustomHistoryEntry[], currentCursor: num
return false;
}
const entry = history.at(index);
if (typeof entry === 'string' || !entry || entry.key !== newFocused.key) {
if (!isRecord(entry) || entry.key !== newFocused.key) {
return false;
}
return compoundParamsKey(entry.key, entry.params) === newCompound;
Expand Down Expand Up @@ -134,7 +135,10 @@ function addPushParamsRouterExtension<RouterOptions extends PlatformStackRouterO
return seedCursor(enhanceStateWithHistory(state));
};

const getRehydratedState = (partialState: PartialState<PlatformStackNavigationState<ParamListBase>>, configOptions: RouterConfigOptions) => {
const getRehydratedState = (
partialState: PartialState<PlatformStackNavigationState<ParamListBase>> | PlatformStackNavigationState<ParamListBase>,
configOptions: RouterConfigOptions,
) => {
const state = router.getRehydratedState(partialState, configOptions);
return seedCursor(enhanceStateWithHistory(state));
};
Expand Down Expand Up @@ -178,29 +182,29 @@ function addPushParamsRouterExtension<RouterOptions extends PlatformStackRouterO

// No browser history on native — intercept GO_BACK/POP to revert params to the prior snapshot (what the browser does via popstate on web).
if ((isGoBackAction(action) || isPopAction(action)) && state.history) {
const routeHistoryEntries = state.history.filter((entry): entry is NavigationRoute<ParamListBase, string> => typeof entry !== 'string');
const routeHistoryEntries = state.history.filter((entry) => typeof entry !== 'string');

if (routeHistoryEntries.length > state.routes.length) {
// Index-based, not at(-1) — must match the key PUSH_PARAMS captured under.
const focusedRoute = state.routes.at(state.index) ?? state.routes.at(-1);
if (focusedRoute) {
// Cursor-relative, not last-two: last-two drifts out of sync after a mid-cursor browser-back RESET.
const history = state.history as CustomHistoryEntry[];
const history = state.history;
const currentIdx = pushParamsHistoryPosition >= 0 && pushParamsHistoryPosition < history.length ? pushParamsHistoryPosition : history.length - 1;
const currentEntry = history.at(currentIdx);

if (currentEntry && typeof currentEntry !== 'string' && currentEntry.key === focusedRoute.key) {
if (isRecord(currentEntry) && currentEntry.key === focusedRoute.key) {
let prevIdx = -1;
for (let i = currentIdx - 1; i >= 0; i -= 1) {
const e = history.at(i);
if (e && typeof e !== 'string' && e.key === focusedRoute.key) {
if (isRecord(e) && e.key === focusedRoute.key) {
prevIdx = i;
break;
}
}

if (prevIdx >= 0) {
const prevEntry = history.at(prevIdx) as NavigationRoute<ParamListBase, string>;
const prevEntry = history.at(prevIdx);
if (prevIdx >= 0 && isRecord(prevEntry) && (prevEntry.params === undefined || (typeof prevEntry.params === 'object' && prevEntry.params !== null))) {
const targetParams = prevEntry.params;
const routes = [...state.routes];
routes[state.index] = {
Expand Down Expand Up @@ -232,7 +236,7 @@ function addPushParamsRouterExtension<RouterOptions extends PlatformStackRouterO
if (!newState) {
return null;
}
const preservedHistory = preserveHistoryForRoutes(state.history as CustomHistoryEntry[], newState.routes);
const preservedHistory = preserveHistoryForRoutes(state.history, newState.routes);
// Sync cursor to the focused entry of the filtered history — same invariant as the fall-through path.
pushParamsHistoryPosition = preservedHistory.length > 0 ? preservedHistory.length - 1 : -1;
return {
Expand All @@ -254,14 +258,13 @@ function addPushParamsRouterExtension<RouterOptions extends PlatformStackRouterO
};
}

// @ts-expect-error newState may be partial, but getRehydratedState handles both partial and full states correctly.
const rehydratedState = getRehydratedState(newState, configOptions);

// RESET (e.g. web URL sync) would wipe PUSH_PARAMS snapshots via 1:1 rehydration — preserve entries for surviving routes.
if (action.type === CONST.NAVIGATION.ACTION_TYPE.RESET && state.history) {
// CommonActions.reset can install any index — pick the focused route, not routes.at(-1).
const newFocused = rehydratedState.routes.at(rehydratedState.index) ?? rehydratedState.routes.at(-1);
const history = state.history as CustomHistoryEntry[];
const history = state.history;
if (newFocused?.key) {
const outcome = resolveCursorForReset(history, pushParamsHistoryPosition, {key: newFocused.key, params: newFocused.params});
if (outcome.type === 'backward' || outcome.type === 'ambiguous') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,15 @@ import type {PendingReveal, RootHistoryState} from './addRootHistoryRouterExtens

import {
applyRevealPaddingOffset,
asCustomHistory,
getFrozenHistoryStateForRemoveFullscreenUnderRHP,
getFrozenHistoryStateForReplaceFullscreenUnderRHP,
getRevealDismissState,
getTrailingStringSentinels,
getTrailingStringEntries,
isDismissModalAction,
isModalHistorySentinel,
isModalHistoryGuardEntry,
isRemoveFullscreenUnderRHPAction,
isReplaceFullscreenUnderRHPAction,
stripTrailingModalSentinels,
removeTopModalHistoryGuardEntry,
} from './addRootHistoryRouterExtensionUtils';
import {enhanceStateWithHistory} from './utils';

Expand All @@ -39,25 +38,24 @@ function addRootHistoryRouterExtension<RouterOptions extends PlatformStackRouter
return enhanceStateWithHistory(state);
};

const getRehydratedState = (partialState: PartialState<PlatformStackNavigationState<ParamListBase>>, configOptions: RouterConfigOptions) => {
const getRehydratedState = (partialState: PartialState<RootHistoryState> | RootHistoryState, configOptions: RouterConfigOptions) => {
const state = router.getRehydratedState(partialState, configOptions);
const stateWithInitialHistory = enhanceStateWithHistory(state);

// Preserve the trailing run of string entries (side-panel + per-modal back-guards) through
// state rebuilds, so those overlays stay open and their browser entries aren't stranded by a
// benign history rebuild (e.g. RESET / resize). The forward-navigation consume in
// getStateForAction is what intentionally drops a modal back-guard entry.
const trailingSentinels = getTrailingStringSentinels(state.history);
if (trailingSentinels.length > 0) {
stateWithInitialHistory.history = [...(asCustomHistory(stateWithInitialHistory.history) ?? []), ...trailingSentinels];
const trailingStringEntries = getTrailingStringEntries(state.history);
if (trailingStringEntries.length > 0) {
stateWithInitialHistory.history = [...(stateWithInitialHistory.history ?? []), ...trailingStringEntries];
}

return stateWithInitialHistory;
};

// Centralizes the `PartialState | FullState` cast to `getRehydratedState`'s input shape.
function rehydrate(newState: PartialState<RootHistoryState> | RootHistoryState, configOptions: RouterConfigOptions) {
return getRehydratedState(newState as PartialState<RootHistoryState>, configOptions);
return getRehydratedState(newState, configOptions);
}

const getStateForAction = (state: RootHistoryState, action: RootStackNavigatorAction, configOptions: RouterConfigOptions) => {
Expand Down Expand Up @@ -112,8 +110,8 @@ function addRootHistoryRouterExtension<RouterOptions extends PlatformStackRouter
// unaffected. Either ordering works: if the Modal's own toggle(false) ran first, the trailing
// entry is already a route and this is a no-op.
const isForwardNavigation = action.type === CONST.NAVIGATION.ACTION_TYPE.PUSH || action.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE;
if (isForwardNavigation && isModalHistorySentinel(asCustomHistory(state.history)?.at(-1))) {
const consumedHistory = stripTrailingModalSentinels(asCustomHistory(rehydrated.history) ?? []);
if (isForwardNavigation && isModalHistoryGuardEntry(state.history?.at(-1))) {
const consumedHistory = removeTopModalHistoryGuardEntry(rehydrated.history ?? []);
return applyRevealPaddingOffset(state, {...rehydrated, history: consumedHistory});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import NAVIGATORS from '@src/NAVIGATORS';

import type {ParamListBase, PartialState, RouterConfigOptions} from '@react-navigation/native';

import type {CustomHistoryEntry} from './types';

type RootHistoryState = PlatformStackNavigationState<ParamListBase>;
type PendingReveal = {rhpKey: string; routesLengthAtCapture: number; historyLengthAtCapture: number};
type RehydrateRootHistoryState = (newState: PartialState<RootHistoryState> | RootHistoryState, configOptions: RouterConfigOptions) => RootHistoryState;
Expand All @@ -31,7 +29,7 @@ function isDismissModalAction(action: RootStackNavigatorAction): action is Dismi
}

/** Returns true if the entry is a per-instance Modal back-guard (`CUSTOM_HISTORY_ENTRY_MODAL:<modalId>`). */
function isModalHistorySentinel(entry: CustomHistoryEntry | undefined): boolean {
function isModalHistoryGuardEntry(entry: unknown): boolean {
return typeof entry === 'string' && entry.startsWith(`${CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MODAL}:`);
}

Expand All @@ -40,21 +38,20 @@ function isModalHistorySentinel(entry: CustomHistoryEntry | undefined): boolean
* re-appended after `enhanceStateWithHistory` rebuilds `history` from `routes`. This keeps those
* overlays' browser entries alive through benign history rebuilds (e.g. RESET / resize).
*/
function getTrailingStringSentinels(history: unknown[] | undefined): string[] {
const typed = asCustomHistory(history);
if (!typed?.length) {
function getTrailingStringEntries(history: unknown[] | undefined): string[] {
if (!history?.length) {
return [];
}
let cutoff = typed.length;
while (cutoff > 0 && typeof typed.at(cutoff - 1) === 'string') {
let cutoff = history.length;
while (cutoff > 0 && typeof history.at(cutoff - 1) === 'string') {
cutoff -= 1;
}
return typed.slice(cutoff).filter((entry): entry is string => typeof entry === 'string');
return history.slice(cutoff).filter((entry): entry is string => typeof entry === 'string');
}

/** Removes only the topmost modal back-guard entry (used to consume one guard on forward navigation). */
function stripTrailingModalSentinels(history: CustomHistoryEntry[]): CustomHistoryEntry[] {
if (isModalHistorySentinel(history.at(-1))) {
function removeTopModalHistoryGuardEntry(history: unknown[]): unknown[] {
if (isModalHistoryGuardEntry(history.at(-1))) {
return history.slice(0, -1);
}
return history;
Expand All @@ -64,13 +61,8 @@ function isRightModalNavigatorRouteName(name: string | undefined): boolean {
return name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR;
}

/** Centralizes the cast from PlatformStackNavigationState's `unknown[]` history slot to our typed array. */
function asCustomHistory(history: unknown[] | undefined): CustomHistoryEntry[] | undefined {
return history as CustomHistoryEntry[] | undefined;
}

/** Counts the leading `CUSTOM_HISTORY_ENTRY_REVEAL_PADDING` padding entries in a history array. */
function countLeadingRevealPadding(history: CustomHistoryEntry[] | undefined): number {
function countLeadingRevealPadding(history: unknown[] | undefined): number {
if (!history?.length) {
return 0;
}
Expand All @@ -87,11 +79,11 @@ function countLeadingRevealPadding(history: CustomHistoryEntry[] | undefined): n
}

/** Returns a fresh history array with `offset` reveal-padding entries prepended. */
function buildPaddedHistory(baseHistory: CustomHistoryEntry[], offset: number): CustomHistoryEntry[] {
function buildPaddedHistory(baseHistory: unknown[], offset: number): unknown[] {
if (offset <= 0) {
return [...baseHistory];
}
const padding = new Array<CustomHistoryEntry>(offset).fill(CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_REVEAL_PADDING);
const padding = new Array<string>(offset).fill(CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_REVEAL_PADDING);
return [...padding, ...baseHistory];
}

Expand Down Expand Up @@ -154,7 +146,7 @@ function getRevealDismissState(
// Apply the reveal fix only when this DISMISS closes the same RHP we snapshotted.
if (dismissingTopKey === pendingReveal.rhpKey && depthMatches && historyDepthMatches) {
const rehydrated = rehydrate(newState, configOptions);
const rehydratedHistory = asCustomHistory(rehydrated.history) ?? [];
const rehydratedHistory = rehydrated.history ?? [];
// rehydratedHistory already includes any trailing SIDE_PANEL entry,
// so it does not inflate the computed offset.
const lengthDelta = (state.history?.length ?? 0) - rehydratedHistory.length;
Expand Down Expand Up @@ -183,24 +175,23 @@ function getRevealDismissState(

function applyRevealPaddingOffset(state: RootHistoryState, rehydrated: RootHistoryState): RootHistoryState {
// Regular navigation rebuilds history from routes; put back any fake slots already in use.
const offset = countLeadingRevealPadding(asCustomHistory(state.history));
const offset = countLeadingRevealPadding(state.history);
if (offset > 0) {
return {...rehydrated, history: buildPaddedHistory(asCustomHistory(rehydrated.history) ?? [], offset)};
return {...rehydrated, history: buildPaddedHistory(rehydrated.history ?? [], offset)};
}
return rehydrated;
}

export type {PendingReveal, RootHistoryState};
export {
applyRevealPaddingOffset,
asCustomHistory,
getFrozenHistoryStateForRemoveFullscreenUnderRHP,
getFrozenHistoryStateForReplaceFullscreenUnderRHP,
getRevealDismissState,
getTrailingStringSentinels,
getTrailingStringEntries,
isDismissModalAction,
isModalHistorySentinel,
isModalHistoryGuardEntry,
isRemoveFullscreenUnderRHPAction,
isReplaceFullscreenUnderRHPAction,
stripTrailingModalSentinels,
removeTopModalHistoryGuardEntry,
};
4 changes: 2 additions & 2 deletions src/libs/TransactionUtils/amountUtils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import CONST from '@src/CONST';
import type {Transaction} from '@src/types/onyx';
import type {OnyxInputOrEntry, Transaction} from '@src/types/onyx';

import type {OnyxEntry} from 'react-native-onyx';

function hasValidModifiedAmount(transaction: OnyxEntry<Transaction> | null): boolean {
function hasValidModifiedAmount(transaction: OnyxInputOrEntry<Partial<Pick<Transaction, 'modifiedAmount'>>>): boolean {
if (!transaction) {
return false;
}
Expand Down
Loading
Loading