Skip to content

[Due for payment 2026-07-29] [$250] Chat - The new group chat doesn't open in Firefox #94571

Closed

Description

@nlemma

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


Version Number: 9.4.19-1
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: https://github.com/Expensify/Expensify/issues/537811
Email or phone of affected tester (no customers): -
Issue reported by: Applause Internal Team
Bug source: Regression TC Execution
Device used: macOS Sequoia 15.4.1 - Firefox

Action Performed:

(Using Firefox)

  1. Log in to newDot
  2. Click the + LHN Action Button
  3. Click Start Chat
  4. Click Add to group
  5. Click Next then Start Group

Expected Result:

The newly created group chat should be opened

Actual Result:

The user stays in the previous chat room

Workaround:

Unknown

Platforms:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Screenshots/Videos

Bug7181693_1781557777797.Recording__2369.mp4

View all open jobs on GitHub

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Jun 25, 2026
  2. MelvinBot commented on Jun 25, 2026

    @MelvinBot
    Contributor

    Proposal

    ⚠️ Medium confidence: the code path is firmly identified and frontend-only, but the precise reason Firefox alone fails needs a Firefox repro with logging to nail down (see open question at the end).

    Please re-state the problem that we are trying to solve in this issue.

    In Firefox, creating a group chat (Start Chat → Add to group → Next → Start Group) does not open the new chat — the user is left in the previously-open report. It works in Chrome/Safari.

    What is the root cause of that problem?

    This is a client-side navigation timing race, not a backend issue. The group report is created optimistically and navigation never waits on the server, so the failure is purely in the dismiss-modal-then-navigate sequence.

    "Start Group" → createGroup → navigateToAndCreateGroupChat → navigateToReport(newReportID, {afterTransition: clearGroupChat}).

    navigateToReport does two things in sequence:

    1. Navigation.dismissModal() — closes the New Chat RHP modal.
    2. On a separate setTimeout(0) → Navigation.navigate(REPORT_WITH_ID) to open the new report.

    The code comment on that setTimeout ("In some cases when RHP modal gets hidden and then we navigate to report Composer focus breaks...") already documents this sequence as timing-fragile.

    Both the modal dismissal and the navigation are gated through TransitionTracker, whose "modal close finished" signal on web is fired by a react-native-reanimated Keyframe exit-animation callback: new Keyframe(...).withCallback(() => onCloseCallbackRef.current()). That same file already warns these web callbacks are unreliable across engines ("on web the callbacks are not called when animations are disabled with the reduced motion setting on"). Reanimated/Web Animations completion callbacks are historically inconsistent in Firefox, so the dismiss-vs-navigate ordering resolves differently there: the modal-close work lands after the queued navigate and effectively supersedes it, leaving the user on the previous chat.

    There is no Firefox-specific guard anywhere in this navigation/modal path today (getBrowser() exists but isn't used here).

    Investigation details & evidence chain
    • Entry point: NewChatConfirmPage.tsx:215-219 (onConfirm: createGroup).
    • Optimistic reportID is generated client-side, so navigation does not depend on the API response.
    • Navigation.dismissModal and Navigation.navigate both route through TransitionTracker.runAfterTransitions.
    • TransitionTracker flushes queued callbacks only when activeTransitions reaches 0, with a 1000ms (MAX_TRANSITION_DURATION_MS) safety-net timeout — so a missed/late close callback strands or re-orders the queued navigate.
    • onCloseCallBack → TransitionTracker.endTransition is at ReanimatedModal/index.tsx:162-179.
    • No Firefox-specific branch exists; Firefox follows the generic web path, so the divergence comes from browser-engine animation-callback timing.

    What changes do you think we should make in order to solve the problem?

    Make the navigate-to-new-report independent of the modal-close animation callback firing, so Firefox can't drop it:

    • In navigateToReport, use the navigation API that performs dismiss + navigate atomically (e.g. Navigation.dismissModalWithReport / passing the target report to the dismiss) instead of dismissModal() followed by a decoupled setTimeout(0) navigate. This removes the ordering race between the two dispatches.
    • And/or guarantee the modal-close transition always completes on web: have Container/index.web.tsx back the withCallback with a setTimeout(animationOutTiming) fallback so onCloseCallBack/endTransition fires even when the Reanimated/WAAPI completion callback doesn't fire in Firefox.

    What alternative solutions did you explore?

    • A Firefox-only branch via getBrowser() to skip the deferred-navigate timing — works but adds a browser special-case the codebase otherwise avoids; the atomic dismiss-and-navigate fix is preferable as it helps all engines.

    Open question to confirm before implementing

    We should reproduce in Firefox with logging around Container/index.web.tsx:56 and the DISMISS_MODAL dispatch to confirm whether (a) the exit-animation callback fails to fire, or (b) the dismiss dispatch lands after the queued navigate. Both point at the same fix area, but the repro tells us which of the two changes above is sufficient.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/94571) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Jun 25, 2026
  4. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Jun 25, 2026
  5. nabi-ebrahimi commented on Jun 25, 2026

    @nabi-ebrahimi
    Contributor

    Proposal

    What is the root cause of that problem?

    The group chat itself is created correctly. The failure is in the navigation that happens right after the optimistic report is created.

    NewChatConfirmPage calls navigateToAndCreateGroupChat(), which creates the optimistic group report and then calls:

    navigateToReport(newChat.reportID, {afterTransition: clearGroupChat});

    Inside navigateToReport(), the default path does two separate things:

    1. Calls Navigation.dismissModal() to close the new chat RHP.
    2. Schedules Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)) in a setTimeout(0).

    Those two operations are not actually sequenced. The code assumes the modal dismissal will be far enough along by the time the timer fires, but Firefox does not consistently resolve that ordering the same way. As a result, the group report is created and can appear in the LHN, but the visible report stays on the previously selected chat.

    Relevant code paths:

    • const createGroup = () => {
      if (!newGroupDraft) {
      return;
      }
      // Build the participants' personal details so the action can derive logins/accountIDs and tell which participants are new
      // (entries flagged with isOptimisticPersonalDetail) without reading the global personal details list.
      const participantsPersonalDetails: PersonalDetailsList = {};
      for (const participant of newGroupDraft.participants ?? []) {
      const {accountID, login} = participant;
      if (!accountID || !login) {
      continue;
      }
      participantsPersonalDetails[accountID] = allPersonalDetails?.[accountID] ?? {
      accountID,
      login,
      displayName: login,
      isOptimisticPersonalDetail: true,
      };
      }
      navigateToAndCreateGroupChat(
      participantsPersonalDetails,
      newGroupDraft.reportName ?? '',
      personalData.login ?? '',
      optimisticReportID.current,
      introSelected,
      isSelfTourViewed,
      betas,
      personalData.accountID,
      newGroupDraft.avatarUri ?? '',
    • function navigateToReport(reportID: string | undefined, options?: {shouldDismissModal?: boolean; afterTransition?: () => void}) {
      const shouldDismissModal = options?.shouldDismissModal ?? true;
      if (shouldDismissModal) {
      if (!reportID) {
      Navigation.dismissModal({afterTransition: options?.afterTransition});
      return;
      }
      Navigation.dismissModal();
      }
      if (!reportID) {
      return;
      }
      // In some cases when RHP modal gets hidden and then we navigate to report Composer focus breaks, wrapping navigation in setTimeout fixes this
      setTimeout(() => {
      Navigation.isNavigationReady().then(() => {
      const route = ROUTES.REPORT_WITH_ID.getRoute(reportID);
      if (options?.afterTransition) {
      Navigation.navigate(route, {afterTransition: options.afterTransition});
      } else {
      Navigation.navigate(route);
      }
      });
      }, 0);
      }
    • function navigateToAndCreateGroupChat(
      participantsPersonalDetails: OnyxEntry<PersonalDetailsList>,
      reportName: string,
      currentUserLogin: string,
      optimisticReportID: string,
      introSelected: OnyxEntry<IntroSelected>,
      isSelfTourViewed: boolean | undefined,
      betas: OnyxEntry<Beta[]>,
      currentUserAccountID: number,
      avatarUri?: string,
      avatarFile?: File | CustomRNImageManipulatorResult | undefined,
      ) {
      const userLogins = Object.values(participantsPersonalDetails ?? {})
      .map((participant) => participant?.login)
      .filter((login): login is string => !!login);
      const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(userLogins);
      // If we are creating a group chat then participantAccountIDs is expected to contain currentUserAccountID
      const newChat = buildOptimisticGroupChatReport(participantAccountIDs, reportName, avatarUri ?? '', currentUserAccountID, optimisticReportID, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
      createGroupChat(newChat.reportID, participantsPersonalDetails, newChat, currentUserLogin, introSelected, isSelfTourViewed, betas, avatarFile);
      navigateToReport(newChat.reportID, {afterTransition: clearGroupChat});

    What changes do you think we should make in order to solve the problem?

    Update navigateToReport() so the “dismiss modal, then open report” case uses the existing modal-aware helper instead of doing those as two separate navigation operations.

    When shouldDismissModal is true and reportID is defined, call:

    Navigation.dismissModalWithReport({reportID}, undefined, {afterTransition: options?.afterTransition});

    and return early.

    Keep the current dismiss-only behavior when there is no reportID, and keep the direct Navigation.navigate() behavior for callers that pass shouldDismissModal: false.

    This is better than manually nesting Navigation.navigate() inside dismissModal({afterTransition}) because dismissModalWithReport() already handles the report-specific edge cases, including when the target report is already active and when the topmost route is a super-wide RHP report.

    It also preserves the existing afterTransition callback, so clearGroupChat still runs after the report navigation flow rather than immediately after the optimistic report is created.

    The existing helper already implements the sequencing we need here:

    const dismissModalWithReport = (
    {reportID, reportActionID, referrer, backTo}: ReportsSplitNavigatorParamList[typeof SCREENS.REPORT],
    ref = navigationRef,
    options?: {onBeforeNavigate?: (willOpenReport: boolean) => void; afterTransition?: () => void},
    ) => {
    const dismissAndOpenReport = () => {
    const topmostSuperWideRHPReportID = getTopmostSuperWideRHPReportID();
    let areReportsIDsDefined = !!topmostSuperWideRHPReportID && !!reportID;
    if (topmostSuperWideRHPReportID === reportID && areReportsIDsDefined) {
    options?.onBeforeNavigate?.(false);
    dismissToSuperWideRHP({afterTransition: options?.afterTransition});
    return;
    }
    const topmostReportID = getTopmostReportId();
    areReportsIDsDefined = !!topmostReportID && !!reportID;
    const isReportsSplitTopmostFullScreen = isReportTopmostSplitNavigator();
    if (topmostReportID === reportID && areReportsIDsDefined && isReportsSplitTopmostFullScreen) {
    options?.onBeforeNavigate?.(false);
    dismissModal({afterTransition: options?.afterTransition});
    return;
    }
    options?.onBeforeNavigate?.(true);
    const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(reportID, reportActionID, referrer, backTo);
    dismissModal({
    afterTransition: () => {
    navigate(reportRoute, {afterTransition: options?.afterTransition});
    },
    });

    Add or update the Report.navigateToAndCreateGroupChat() action test to verify that starting a group chat routes through:

    Navigation.dismissModalWithReport(
        {reportID: optimisticGroupReportID},
        undefined,
        {afterTransition: expect.any(Function)},
    );

    instead of dismissing the modal and navigating to the report independently.

    We should also manually regression-test the Firefox web flow:

    Start Chat -> Add to group -> Next -> Start Group

    and confirm the newly created group opens.

    Fixed Demo:

    REC-20260625174635.mp4

    What alternative solutions did you explore? (Optional)

    I considered changing only navigateToAndCreateGroupChat() to call dismissModalWithReport() directly, but the fragile sequencing is in the shared navigateToReport() helper. Fixing the helper also covers nearby create/open-report flows that dismiss a modal before opening a report.

    I also considered keeping the current structure and only delaying the Navigation.navigate() call longer, but that would still be timing-based. The code already has a helper for this exact dismiss-then-open-report flow, so we should use that instead of relying on a timer.

  6. MobileMage commented on Jun 25, 2026

    @MobileMage
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    In Firefox on macOS, creating a new group chat (+ → Start Chat → Add to group → Next → Start Group) does not open the newly created group. The modal closes but the user is left on whatever chat they were previously viewing instead of being taken to the new group room. This reproduces in staging and production and is specific to Firefox.

    What is the root cause of that problem?

    The group-creation confirm handler createGroup in NewChatConfirmPage calls navigateToAndCreateGroupChat, which builds the optimistic group report and then routes the user with navigateToReport(newChat.reportID, {afterTransition: clearGroupChat}):

    const newChat = buildOptimisticGroupChatReport(participantAccountIDs, reportName, avatarUri ?? '', currentUserAccountID, optimisticReportID, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
    createGroupChat(newChat.reportID, participantsPersonalDetails, newChat, currentUserLogin, introSelected, isSelfTourViewed, betas, avatarFile);
    navigateToReport(newChat.reportID, {afterTransition: clearGroupChat});

    Inside navigateToReport, shouldDismissModal defaults to true, so it fires Navigation.dismissModal() (which dispatches DISMISS_MODAL → StackActions.pop() to close the RHP and starts the modal-close transition), and then schedules the navigation to the new report in a bare setTimeout(0):

    Navigation.dismissModal();
    }
    if (!reportID) {
    return;
    }
    // In some cases when RHP modal gets hidden and then we navigate to report Composer focus breaks, wrapping navigation in setTimeout fixes this
    setTimeout(() => {
    Navigation.isNavigationReady().then(() => {
    const route = ROUTES.REPORT_WITH_ID.getRoute(reportID);
    if (options?.afterTransition) {
    Navigation.navigate(route, {afterTransition: options.afterTransition});
    } else {
    Navigation.navigate(route);
    }
    });
    }, 0);

    The two navigation dispatches are not chained — they race. dismissModal() pops the modal and triggers a transitionStart/transitionEnd cycle tracked by TransitionTracker (a pop animation drives ScreenLayout's transitionStart listener, incrementing activeTransitions). Meanwhile the setTimeout(0) callback later calls Navigation.navigate(route), and navigate runs its linkTo with runImmediately = true (no waitForTransition), so the report-push is dispatched independently of when the modal-close pop actually settles. On Chrome the modal-close pop animation and the setTimeout(0) navigate happen to interleave such that the report push lands on the resulting state. Firefox sequences the requestAnimationFrame-driven transition events and the setTimeout(0) macrotask differently, so the navigate fires against a navigation state that is still mid-pop; the report push is superseded/dropped by the in-flight DISMISS_MODAL pop, and the user is left on the previous chat. This is the classic dismiss-then-navigate race — note the canonical helpers in the codebase never use a free-floating setTimeout; they chain the navigate to the dismiss completion (submitWithDismissFirst and dismissModalWithReport both pass the navigate as the dismiss's afterTransition).

    What changes do you think we should make in order to solve the problem?

    Stop racing the navigate against the modal dismiss inside navigateToReport, and instead chain it to the dismiss completion exactly like the other dismiss-then-navigate flows. When shouldDismissModal is true, pass the report navigation as dismissModal's afterTransition callback so the report push only runs once the DISMISS_MODAL pop transition has settled, with clearGroupChat chained after that. The existing TransitionTracker.runAfterTransitions({waitForUpcomingTransition: true}) path already guarantees this fires after the pop (and has a MAX_TRANSITION_START_WAIT_MS safety net), so no new timing primitives are needed. The core change inside navigateToReport:

    const route = ROUTES.REPORT_WITH_ID.getRoute(reportID);
    const navigateToTargetReport = () => Navigation.navigate(route, options?.afterTransition ? {afterTransition: options.afterTransition} : undefined);
    if (shouldDismissModal) {
        Navigation.dismissModal({afterTransition: navigateToTargetReport});
        return;
    }
    navigateToTargetReport();

    This keeps the non-dismiss callers (the shouldDismissModal: false paths at L2414/L2423/L2449) on the existing immediate-navigate behavior, and the existing DM/group-creation callers continue to dismiss first — only now the report push is deterministically ordered after the modal close instead of guessed via setTimeout(0). Because the navigate is a real React Navigation dispatch (not a focus workaround), this also removes the fragile Composer-focus setTimeout comment justification while preserving correct ordering across Firefox, Chrome, and native.

    What alternative solutions did you explore? (Optional)

    Bumping the setTimeout(0) delay or wrapping the navigate in requestAnimationFrame would paper over the Firefox timing without removing the race, and would slow the transition on every browser; chaining off the dismiss transition is both deterministic and consistent with the established submitWithDismissFirst / dismissModalWithReport pattern.

  7. x-dev90 commented on Jun 25, 2026

    @x-dev90
    Contributor

    Proposal

    What is the root cause of that problem?

    The group chat is created, but the app does not reliably navigate to it on Firefox because the group creation flow dismisses the modal and opens the new report as two separate navigation operations.

    NewChatConfirmPage calls navigateToAndCreateGroupChat() when the user presses Start Group:

    const createGroup = () => {
    if (!newGroupDraft) {
    return;
    }
    // Build the participants' personal details so the action can derive logins/accountIDs and tell which participants are new
    // (entries flagged with isOptimisticPersonalDetail) without reading the global personal details list.
    const participantsPersonalDetails: PersonalDetailsList = {};
    for (const participant of newGroupDraft.participants ?? []) {
    const {accountID, login} = participant;
    if (!accountID || !login) {
    continue;
    }
    participantsPersonalDetails[accountID] = allPersonalDetails?.[accountID] ?? {
    accountID,
    login,
    displayName: login,
    isOptimisticPersonalDetail: true,
    };
    }
    navigateToAndCreateGroupChat(
    participantsPersonalDetails,
    newGroupDraft.reportName ?? '',
    personalData.login ?? '',
    optimisticReportID.current,
    introSelected,
    isSelfTourViewed,
    betas,
    personalData.accountID,
    newGroupDraft.avatarUri ?? '',
    avatarFile,
    );

    That action creates the optimistic group chat, then calls navigateToReport(newChat.reportID, {afterTransition: clearGroupChat}):

    function navigateToAndCreateGroupChat(
    participantsPersonalDetails: OnyxEntry<PersonalDetailsList>,
    reportName: string,
    currentUserLogin: string,
    optimisticReportID: string,
    introSelected: OnyxEntry<IntroSelected>,
    isSelfTourViewed: boolean | undefined,
    betas: OnyxEntry<Beta[]>,
    currentUserAccountID: number,
    avatarUri?: string,
    avatarFile?: File | CustomRNImageManipulatorResult | undefined,
    ) {
    const userLogins = Object.values(participantsPersonalDetails ?? {})
    .map((participant) => participant?.login)
    .filter((login): login is string => !!login);
    const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(userLogins);
    // If we are creating a group chat then participantAccountIDs is expected to contain currentUserAccountID
    const newChat = buildOptimisticGroupChatReport(participantAccountIDs, reportName, avatarUri ?? '', currentUserAccountID, optimisticReportID, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
    createGroupChat(newChat.reportID, participantsPersonalDetails, newChat, currentUserLogin, introSelected, isSelfTourViewed, betas, avatarFile);
    navigateToReport(newChat.reportID, {afterTransition: clearGroupChat});
    }

    The problem is inside navigateToReport(): it first calls Navigation.dismissModal() and then schedules Navigation.navigate(route) separately with setTimeout(0):

    function navigateToReport(reportID: string | undefined, options?: {shouldDismissModal?: boolean; afterTransition?: () => void}) {
    const shouldDismissModal = options?.shouldDismissModal ?? true;
    if (shouldDismissModal) {
    if (!reportID) {
    Navigation.dismissModal({afterTransition: options?.afterTransition});
    return;
    }
    Navigation.dismissModal();
    }
    if (!reportID) {
    return;
    }
    // In some cases when RHP modal gets hidden and then we navigate to report Composer focus breaks, wrapping navigation in setTimeout fixes this
    setTimeout(() => {
    Navigation.isNavigationReady().then(() => {
    const route = ROUTES.REPORT_WITH_ID.getRoute(reportID);
    if (options?.afterTransition) {
    Navigation.navigate(route, {afterTransition: options.afterTransition});
    } else {
    Navigation.navigate(route);
    }
    });
    }, 0);

    On Firefox, the modal dismissal/browser history update can complete while the separate scheduled report navigation does not become the final active route. The recording confirms this: the new group appears in recents, but the URL and main pane stay on the previous Concierge report.

    What changes do you think we should make in order to solve the problem?

    Use the existing coordinated modal-dismiss-and-report-open helper when navigateToReport() needs to dismiss a modal.

    • If reportID is missing, keep the existing dismiss-only behavior.
    • If shouldDismissModal is true and reportID exists, call Navigation.dismissModalWithReport({reportID}, undefined, {afterTransition}).
    • Return immediately after that coordinated path.
    • Keep the current asynchronous direct navigation path only for shouldDismissModal: false.

    Tests that should be added:

    • Add or update a navigation/action test around group chat creation to verify that starting a group chat dismisses the modal and opens the new report through Navigation.dismissModalWithReport.
    • Manually verify on Firefox web: start from an existing chat, create a group chat via Start Chat > Add to group > Next > Start Group, and confirm the new group report opens.

    I considered changing NewChatConfirmPage to navigate differently only for group chat creation, but that would keep the race-prone helper available for the same modal-to-report flow elsewhere. Fixing navigateToReport() at the shared action/navigation boundary is smaller and handles every caller that wants to dismiss a modal and open a report.

    What alternative solutions did you explore? (Optional)

  8. David-2002065 commented on Jun 25, 2026

    @David-2002065
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-06-25 12:16:45 UTC.

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    After creating a new group chat (+ > Start Chat > Add to group > Next > Start Group), the new group chat does not open in Firefox — the user stays in the previously open chat.

    What is the root cause of that problem?

    Group creation ends in navigateToReport, which dismisses the modal and navigates to the new report as two uncoordinated operations:

    function navigateToReport(reportID: string | undefined, options?: {shouldDismissModal?: boolean; afterTransition?: () => void}) {
    const shouldDismissModal = options?.shouldDismissModal ?? true;
    if (shouldDismissModal) {
    if (!reportID) {
    Navigation.dismissModal({afterTransition: options?.afterTransition});
    return;
    }
    Navigation.dismissModal();
    }
    if (!reportID) {
    return;
    }
    // In some cases when RHP modal gets hidden and then we navigate to report Composer focus breaks, wrapping navigation in setTimeout fixes this
    setTimeout(() => {
    Navigation.isNavigationReady().then(() => {
    const route = ROUTES.REPORT_WITH_ID.getRoute(reportID);
    if (options?.afterTransition) {
    Navigation.navigate(route, {afterTransition: options.afterTransition});
    } else {
    Navigation.navigate(route);
    }
    });
    }, 0);
    }

    Navigation.dismissModal();                         // no afterTransition
    
    setTimeout(() => {
        Navigation.isNavigationReady().then(() => {
            Navigation.navigate(route, {afterTransition: options.afterTransition});
        });
    }, 0);

    dismissModal() is called without its afterTransition hook, and the navigate() is fired from a bare setTimeout(…, 0) with no waitForTransition. Inside navigate, runImmediately = !options?.waitForTransition is therefore true, so the navigation runs immediately rather than queuing behind the in-flight modal-dismiss transition:

    const runImmediately = !options?.waitForTransition;
    TransitionTracker.runAfterTransitions({
    callback: () => {
    const targetRoute = route.startsWith(CONST.SAML_REDIRECT_URL) ? ROUTES.HOME : route;
    linkTo(navigationRef.current, targetRoute, options);
    closeSidePanelOnNarrowScreen(route);
    if (options?.afterTransition) {
    TransitionTracker.runAfterTransitions({callback: options.afterTransition, waitForUpcomingTransition: true});
    }
    },
    runImmediately,

    The fixed 0ms macrotask delay is a best-effort guess at "the modal has finished dismissing". It happens to be long enough on Chromium, but Firefox schedules the modal-dismiss transition differently, so navigate() runs while the RHP dismissal is still in flight and the navigation to the new report gets dropped — leaving the user on the previous chat. This is why it reproduces only in Firefox.

    What changes do you think we should make in order to solve the problem?

    Stop racing a setTimeout, and instead navigate after the dismiss transition actually completes by passing the navigation as the afterTransition of dismissModal. dismissModal already routes afterTransition through runAfterTransitions({..., waitForUpcomingTransition: true}), which waits for the dismiss transition to start and end (with a safety fallback), so ordering is guaranteed across browsers:

    function dismissModal({ref = navigationRef, afterTransition, waitForTransition}: {ref?: NavigationRef; afterTransition?: () => void; waitForTransition?: boolean} = {}) {
    clearSelectedTextIfComposerBlurred();
    const runImmediately = !waitForTransition;
    const performDismiss = () => {
    TransitionTracker.runAfterTransitions({
    callback: () => {
    ref.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL});
    if (afterTransition) {
    TransitionTracker.runAfterTransitions({callback: afterTransition, waitForUpcomingTransition: true});
    }
    },
    runImmediately,
    });
    };
    if (ref.isReady()) {
    performDismiss();
    } else {
    isNavigationReady().then(performDismiss);
    }
    }

    In navigateToReport:

    function navigateToReport(reportID: string | undefined, options?: {shouldDismissModal?: boolean; afterTransition?: () => void}) {
        const shouldDismissModal = options?.shouldDismissModal ?? true;
        const navigateToReportRoute = reportID ? () => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID), {afterTransition: options?.afterTransition}) : undefined;
    
        if (shouldDismissModal) {
            Navigation.dismissModal({afterTransition: navigateToReportRoute ?? options?.afterTransition});
            return;
        }
    
        navigateToReportRoute?.();
    }

    This also unifies the reportID/no-reportID branches, which today already pass afterTransition to dismissModal for the no-reportID case but not for the reportID case.

    Alternatively, the minimal one-line fix is to keep the existing structure but make the navigation queue behind the active dismiss transition instead of running immediately, by passing waitForTransition: true:

    Navigation.navigate(route, {afterTransition: options?.afterTransition, waitForTransition: true});

    Note: the setTimeout(…, 0) was originally added so the RHP modal is hidden before navigating (to avoid Composer focus breaking). Running the navigation as the dismiss afterTransition happens strictly after the transition completes, so that concern is still satisfied — and more reliably than the fixed 0ms delay.

    What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?

    Add a test around navigateToReport/navigateToAndCreateGroupChat asserting that Navigation.navigate to the new report's route is invoked after dismissModal resolves its transition (i.e. ordering does not depend on a setTimeout(0) race), so the new report is the active route after group creation.

    What alternative solutions did you explore? (Optional)

    The waitForTransition: true variant above is the smallest possible change. A heavier alternative would be replacing setTimeout(0) with requestAnimationFrame/InteractionManager, but that keeps the same "guess the timing" fragility that caused the Firefox divergence, rather than explicitly coordinating with the dismiss transition.

  9. aswin-s commented on Jun 25, 2026

    @aswin-s
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-06-25 12:22:24 UTC.

    Proposal

    Problem

    When a Firefox user creates a new group chat (+ LHN → Start Chat → Add to group → Next → Start Group), the app fails to navigate to the newly created group chat and instead leaves the user on the previous chat room. This is a regression on Firefox web only; Chrome and Safari are unaffected.


    Root Cause

    The bug is in navigateToReport in src/libs/actions/Report/index.ts (lines 2240–2265).

    When called with a reportID (the group-chat case), the function follows this path:

    // line 2248 — BROKEN: dismissModal fires without afterTransition
    Navigation.dismissModal();
    
    // line 2254 — navigate fires WHILE dismiss animation is still running
    setTimeout(() => {
        Navigation.isNavigationReady().then(() => {
            Navigation.navigate(route, {afterTransition: clearGroupChat}); // line 2259
        });
    }, 0);

    This fires navigate(route) while the modal dismiss animation is still in progress. In Chrome and Safari, two concurrent navigation actions are processed without conflict. In Firefox, the animation timing causes the report navigation to be dropped, leaving the user on the previous chat.

    There is an asymmetry inside navigateToReport that makes the bug visible:

    • !reportID branch (line 2244): correctly passes afterTransition to dismissModal, so the full callback is properly deferred until after the dismiss animation finishes.
    • reportID branch (line 2248): calls dismissModal() with no afterTransition, then fires navigate() immediately in setTimeout(0) — racing the dismiss animation.

    Secondary effect: runAfterTransitions({callback: clearGroupChat, waitForUpcomingTransition: true}) registered inside navigate waits for the next transitionStart event (via promiseForNextTransitionStart). In Firefox, the dismiss animation's transitionStart may fire after navigate() is called, consuming the promise. This causes clearGroupChat to run after the dismiss transition rather than the report navigation transition — wrong timing, though not the primary cause of the navigation failure.


    Working Analog

    dismissModalWithReport in src/libs/Navigation/Navigation.ts (lines 852–889) already solves this correctly:

    dismissModal({
        afterTransition: () => {
            navigate(reportRoute, {afterTransition: options?.afterTransition});
        },
    });

    This chains: dismiss → wait for dismiss transitionEnd → then navigate → wait for report navigation transitionEnd → then run afterTransition. dismissModal's internal chaining (Navigation.ts:833–835) already handles this correctly:

    if (afterTransition) {
        TransitionTracker.runAfterTransitions({callback: afterTransition, waitForUpcomingTransition: true});
    }

    No change to dismissModal is needed — it already chains afterTransition correctly.


    Fix

    src/libs/actions/Report/index.ts — navigateToReport function (lines 2240–2265)

    When shouldDismissModal && reportID, mirror the dismissModalWithReport pattern: nest the navigate call inside dismissModal's afterTransition instead of firing it concurrently:

    function navigateToReport(reportID: string | undefined, options?: {shouldDismissModal?: boolean; afterTransition?: () => void}) {
        const shouldDismissModal = options?.shouldDismissModal ?? true;
    
        if (shouldDismissModal) {
            if (!reportID) {
                Navigation.dismissModal({afterTransition: options?.afterTransition});
                return;
            }
            // Chain navigate inside dismissModal's afterTransition so it only fires after
            // the dismiss animation completes — matching the dismissModalWithReport pattern.
            Navigation.dismissModal({
                afterTransition: () => {
                    // setTimeout preserves the existing fix for Composer focus breaking
                    // when RHP modal hides and navigation occurs (see original comment).
                    setTimeout(() => {
                        Navigation.isNavigationReady().then(() => {
                            const route = ROUTES.REPORT_WITH_ID.getRoute(reportID);
                            Navigation.navigate(route, {afterTransition: options?.afterTransition});
                        });
                    }, 0);
                },
            });
            return;
        }
    
        if (!reportID) {
            return;
        }
        setTimeout(() => {
            Navigation.isNavigationReady().then(() => {
                const route = ROUTES.REPORT_WITH_ID.getRoute(reportID);
                if (options?.afterTransition) {
                    Navigation.navigate(route, {afterTransition: options.afterTransition});
                } else {
                    Navigation.navigate(route);
                }
            });
        }, 0);
    }

    The correct chaining order is now:

    1. dismissModal() dispatches DISMISS_MODAL
    2. Dismiss transitionStart fires → TransitionTracker.startTransition()
    3. Dismiss transitionEnd fires → TransitionTracker.endTransition()
    4. dismissModal's afterTransition callback fires
    5. setTimeout(0) → navigate(route, {afterTransition: clearGroupChat})
    6. Report navigation transitionStart → transitionEnd
    7. clearGroupChat() runs (clears ONYXKEYS.NEW_GROUP_CHAT_DRAFT)

    Callers

    All callers of navigateToReport with a non-undefined reportID benefit from this fix:

    • navigateToAndCreateGroupChat (Report/index.ts:2369) — primary bug site
    • navigateToAndOpenReport (Report/index.ts:2274)
    • navigateToAndOpenReportWithAccountIDs (Report/index.ts:2377)

    shouldDismissModal: false callers skip the modal-dismiss path entirely and are unaffected.


    Alternatives Considered

    • Increase MAX_TRANSITION_START_WAIT_MS — symptom workaround; slows all platforms and does not fix the root race condition.
    • Move clearGroupChat to dismissModal's afterTransition directly — incorrect; this would clear the group chat draft after dismiss but never navigate to the new report.
    • Remove setTimeout(0) wrapper inside the chained call — the setTimeout(0) comment in the original code notes it fixes Composer focus breaking when navigating immediately after an RHP modal closes; it should be preserved inside the chained afterTransition.
  10. Abdukhamid000 commented on Jun 25, 2026

    @Abdukhamid000
    Contributor

    Proposal

    What is the root cause of that problem?

    Creating a group chat ends in [navigateToReport](https://github.com/Expensify/App/blob/main/src/libs/actions/Report/index.ts#L2223-L2234), which fires the modal dismiss and the destination navigation as two independent, racing operations:

    function navigateToReport(reportID: string | undefined, shouldDismissModal = true) {
        if (shouldDismissModal) {
            Navigation.dismissModal();
        }
        if (!reportID) {
            return;
        }
        // In some cases when RHP modal gets hidden and then we navigate to report Composer focus breaks, wrapping navigation in setTimeout fixes this
        setTimeout(() => {
            Navigation.isNavigationReady().then(() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)));
        }, 0);
    }

    The flow is: createGroup → [navigateToAndCreateGroupChat](https://github.com/Expensify/App/blob/main/src/libs/actions/Report/index.ts#L2315-L2334) → navigateToReport(newChat.reportID) with the default shouldDismissModal = true.

    Navigation.dismissModal() closes the New Chat RHP, which itself drives a navigation back to the previously open report. Separately, a setTimeout(0) schedules Navigation.navigate(<new group chat>). These two aren't chained — the navigate is a bare macrotask that doesn't wait for the dismiss transition to finish.

    On Chromium/WebKit the ordering happens to land the navigate last, so the new chat opens. Firefox sequences the history updates and the dismiss transition differently, so the modal-dismiss's own navigation (back to the previous report) resolves after the setTimeout(0) navigate — clobbering it. The result is exactly the reported symptom: the user is left in the previous chat room instead of the new group.

    The codebase already has the correct, race-free pattern for this. [dismissModalWithReport](https://github.com/Expensify/App/blob/main/src/libs/Navigation/Navigation.ts#L824-L861) navigates inside the dismiss's afterTransition callback, so the navigate only fires once the modal is fully gone:

    dismissModal({
        afterTransition: () => {
            navigate(reportRoute);
        },
    });

    navigateToReport predates / doesn't use that helper and instead leans on the fragile setTimeout(0), which is what breaks under Firefox's timing.

    What changes do you think we should make in order to solve the problem?

    Chain the navigation to the dismiss completion instead of racing it on a setTimeout. The minimal change is to use dismissModal's afterTransition callback in navigateToReport:

     function navigateToReport(reportID: string | undefined, shouldDismissModal = true) {
    -    if (shouldDismissModal) {
    -        Navigation.dismissModal();
    -    }
         if (!reportID) {
             return;
         }
    -    // In some cases when RHP modal gets hidden and then we navigate to report Composer focus breaks, wrapping navigation in setTimeout fixes this
    -    setTimeout(() => {
    -        Navigation.isNavigationReady().then(() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)));
    -    }, 0);
    +    const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(reportID);
    +    if (shouldDismissModal) {
    +        // Run the navigation after the modal-dismiss transition completes instead of racing it on a
    +        // setTimeout(0). Firefox resolves that race in favor of the dismiss's own navigation, leaving
    +        // the user on the previous report. afterTransition also fires once the modal is fully hidden,
    +        // which preserves the composer-focus fix the setTimeout was originally added for.
    +        Navigation.dismissModal({afterTransition: () => Navigation.navigate(reportRoute)});
    +        return;
    +    }
    +    Navigation.isNavigationReady().then(() => Navigation.navigate(reportRoute));
    }

    afterTransition runs via TransitionTracker.runAfterTransitions after the dismiss completes — strictly more correct than setTimeout(0), and it preserves the composer-focus behavior the old comment was guarding (the navigate still happens after the RHP is hidden). The shouldDismissModal = false callers keep navigating directly with no behavior change.

    A cleaner alternative is to have navigateToReport delegate the dismiss-and-navigate case to the existing, battle-tested [dismissModalWithReport](https://github.com/Expensify/App/blob/main/src/libs/Navigation/Navigation.ts#L824-L861), which already chains via afterTransition and additionally handles the "already on that report" / super-wide-RHP edge cases:

         if (shouldDismissModal) {
    -        Navigation.dismissModal();
    +        Navigation.dismissModalWithReport({reportID});
    +        return;
         }

    Either way the fix is the same idea — stop racing the navigate against the dismiss. I'd lean toward delegating to dismissModalWithReport so there's one canonical dismiss-then-open-report path and the setTimeout hack is removed entirely. All five shouldDismissModal = true call sites (group chat creation + the DM open paths) benefit, so this also fixes the same Firefox race if it shows up when opening a fresh DM.

    What alternative solutions did you explore?

    • Increase the setTimeout delay (e.g. setTimeout(..., 100)). Rejected — it's guessing at Firefox's transition timing; it'd be flaky across machines and still racy. Chaining on the actual transition end is deterministic.
    • Navigate first, then dismiss the modal. Risky ordering in the other direction and can leave the RHP visible mid-navigation; dismissModalWithReport's established order (dismiss → afterTransition navigate) is the proven sequence.
    • Special-case Firefox. A browser sniff to branch navigation logic is brittle and leaves the underlying race in place for any other engine with similar timing. Fixing the chaining addresses the root cause for all browsers.
  11. melvin-bot commented on Jun 25, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @nyomanjyotisa (External)

  12. kitvasyl commented on Jun 25, 2026

    @kitvasyl

    🚫 Duplicated proposal withdrawn by 🤖 ProposalPolice.

  13. 64 remaining items

  14. added
    Awaiting PaymentAuto-added when associated PR is deployed to production
    and removed on Jul 23, 2026
  15. melvin-bot commented on Jul 28, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  16. melvin-bot commented on Jul 28, 2026

    @melvin-bot

    Payment Summary

    Upwork Job

    BugZero Checklist (@mallenexpensify)

    • I have verified the correct assignees and roles are listed above and updated the necessary manual offers
    • I have verified that there are no duplicate or incorrect contracts on Upwork for this job (https://www.upwork.com/ab/applicants/2070118770216781862/hired)
    • I have verified the PR was not reverted
    • I have applied any discounts due to bugs/regressions introduced by this PR
    • I have paid out the Upwork contracts or cancelled the ones that are incorrect
    • I have verified the payment summary above is correct
  17. mallenexpensify commented on Jul 30, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @emkhalid paid $250 via Upwork
    Contributor+: @nyomanjyotisa due $250 via NewDot

    @emkhalid can you please accept the job below? Please reply here and tag me once you have.
    https://www.upwork.com/jobs/~022082910665439359059

  18. emkhalid commented on Jul 31, 2026

    @emkhalid
    Contributor

    @mallenexpensify Thanks for the offer, Accepted.

  19. trjExpensify commented on Aug 3, 2026

    @trjExpensify
    Contributor

    approved $250 for @nyomanjyotisa

  20. mallenexpensify commented on Aug 4, 2026

    @mallenexpensify
    Contributor

    @emkhalid paid, summary updated above. Thx.

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

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions