Skip to content

feat: Replace vacation delegate policy diff warning with RHP invite step - #99460

Open
jakubstec wants to merge 20 commits into
Expensify:mainfrom
software-mansion-labs:jakubstec/policy-diff-warning-rhp-vacation-delegate
Open

jakubstec wants to merge 20 commits into
Expensify:mainfrom
software-mansion-labs:jakubstec/policy-diff-warning-rhp-vacation-delegate

Conversation

@jakubstec

@jakubstec jakubstec commented Aug 25, 2026

Copy link
Copy Markdown
Member

Explanation of Change

Replaces the vacation delegate's blocking "policy diff" warning with an RHP step (VacationDelegateMissingWorkspacesPage). When the delegate isn't a member of every affected workspace, it lets the user invite them straight into the workspaces they admin - any workspace they don't admin still gets its real admins emailed, or skip entirely and just save the delegate, with no invites or emails sent to admin policies.

Fixed Issues

$ #99078
PROPOSAL:

Tests

  1. Go to Settings > Status > Vacation delegate.
  2. Select a user missing only from workspaces you don't administer > verify the RHP shows just the
    "member of" section and a single "Confirm" button > tap Confirm > verify the delegate is saved.
  3. Select a user missing only from workspaces you do administer > verify the RHP shows just the "admin
    of" section with Invite/Skip buttons > tap Invite > verify the delegate is saved and the user is
    added as a member to each listed workspace.
  4. Select a user missing from a mix of both > verify the RHP shows both sections with Invite/Skip
    buttons > tap Skip > verify the delegate is saved without adding the user to any workspace.
  5. Repeat any of the above, but dismiss the RHP (back button/swipe) before tapping a button > verify the
    delegate reverts to what it was before.
  • Verify that no errors appear in the JS console

Offline tests

N/A

QA Steps

Same as Tests

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

MacOS: Chrome / Safari
Screen.Recording.2026-09-04.at.08.36.53.mov
Screenshot 2026-09-04 at 08 38 05

@melvin-bot

melvin-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

Hey, I noticed you changed src/languages/en.ts in a PR from a fork. For security reasons, translations are not generated automatically for PRs from forks.

If you want to automatically generate translations for other locales, an Expensify employee will have to:

  1. Look at the code and make sure there are no malicious changes.
  2. Run the Generate static translations GitHub workflow. If you have write access and the K2 extension, you can simply click: [this button]

Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running:

npx bun ./scripts/generateTranslations.ts --help

Typically, you'd want to translate only what you changed by running npx bun ./scripts/generateTranslations.ts --compare-ref main

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/ROUTES.ts 28.91% <ø> (+2.35%) ⬆️
src/SCREENS.ts 100.00% <ø> (ø)
src/components/VacationDelegateMenuItem.tsx 100.00% <ø> (ø)
src/libs/API/types.ts 100.00% <ø> (ø)
...igation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts 100.00% <ø> (ø)
src/libs/Navigation/linkingConfig/config.ts 71.42% <ø> (ø)
src/libs/UserUtils.ts 84.04% <100.00%> (ø)
src/libs/actions/Domain.ts 65.03% <ø> (+51.46%) ⬆️
src/libs/actions/VacationDelegate.ts 97.87% <100.00%> (+97.87%) ⬆️
src/libs/getVacationDelegateErrors.ts 100.00% <100.00%> (ø)
... and 10 more
... and 642 files with indirect coverage changes

@jakubstec

Copy link
Copy Markdown
Member Author

@codex review

@jakubstec

Copy link
Copy Markdown
Member Author

@MelvinBot review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7316a47f7e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx Outdated
Comment thread src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx Outdated
@MelvinBot

Copy link
Copy Markdown
Contributor

Review — replace vacation delegate policy diff warning with an RHP invite step

Overall this is a clean, well-tested change. The refactor of setVacationDelegate to an options object, the persisted-write-vs-side-effect split (with the offline reasoning in the comments), and the two new test files (action + UI) are all high quality. CI is green apart from the two ignorable checklist checks. I have one behavior question worth confirming before merge, plus a couple of minor robustness nits.

1. Confirm the "Skip" behavior in the mixed case (member of some + admin of some)

In the mixed diff, the copy notAMemberMixed promises two independent things:

Admins of the workspaces you don't manage will be asked to add them. Do you want to invite them to the ones you are an admin of now?

So the admins of the workspaces you don't manage are supposed to be emailed regardless of the Invite/Skip choice — that choice is only about the workspaces you do administer.

But the "Skip" button calls submit(true), i.e. shouldSkipPolicyInviteEmails: true, which is the single Skip shown for both "admin of all" and "mixed" diffs. If skipPolicyInviteEmails suppresses all invite emails on the backend, then Skip-in-mixed would silently drop the emails to the non-admin workspaces' admins — contradicting the copy. It's only harmless if the flag is scoped on the backend to only the caller's admin workspaces.

Could you confirm the backend semantics of skipPolicyInviteEmails? If it's a global "skip all emails" flag, mixed-case Skip should probably be submit(false) (still email the non-admin workspaces' admins) rather than submit(true).

2. Minor robustness nits

  • getMenuItemsForPolicies uses policies?.[…]?.name directly for title. If a policy in the diff isn't in Onyx yet, the row renders with an empty title. A fallback (e.g. the policy name or a generic label) would render more gracefully.
  • In onSelectRow, any non-SUCCESS, non-305 response — including an undefined response — falls into showErrorModal. Worth double-checking the selection row can't be triggered while offline (the side-effect request won't resolve), so a user doesn't get a spurious generic error.
What I checked
  • Verified component/util signatures used by the new page against main: addMembersToWorkspace (8-arg call matches), getMemberAccountIDsForWorkspace(employeeList, false, false), getAllPolicyExpenseChatReportActions, Button.Text composed pattern, styles.renderHTML, CONST.JSON_CODE.POLICY_DIFF_WARNING = 305.
  • Confirmed all referenced translation keys exist (common.invite/skip/confirm/vacationDelegate, statusPage.vacationDelegate.*, workspace.common.welcomeNote/workspace).
  • Walked the state machine: optimistic pendingAction: UPDATE on select → 305 merges policyDiff + clears pendingAction (no error written, avoiding a red brick road) → RHP step → override branch uses a persisted API.write so the delegate can't be dropped offline behind the invites. The unmount rollback + submittedInput snapshot to survive the flow-state null are both covered by tests.
  • Direct interpolation of the delegate email into <strong>${email}</strong> for RenderHTML matches the established app-wide pattern in en.ts, so no new concern there.
  • The Invite path (client addMembersToWorkspace per admin policy, then submit(false)) is sequenced correctly behind the persisted queue so the backend only emails the remaining non-admin workspaces — nicely done.

I did not exercise the flow in a live session: reaching the new RHP step requires a 305 policy-diff response, which needs a delegate who is missing from a specific mix of admin/non-admin workspaces — not reproducible against the test account. The mixed-case Invite/Skip path (item 1) is the key scenario to cover in manual QA.


view run

- Debounce rapid delegate-row taps so overlapping SET_VACATION_DELEGATE
  requests can't race and navigate on a stale response
- Keep the missing-workspaces screen rendering from the submitted input
  instead of Onyx once submit clears the flow state, so it no longer
  flashes NotFoundPage/the previous delegate while the RHP is closing
- Only roll back the delegate on unmount if this screen ever saw an
  active policy-diff flow, so a stray visit after the flow ended can't
  wipe out an unrelated, already-saved delegate
- Fall back to a placeholder name for a workspace missing from Onyx
@jakubstec

Copy link
Copy Markdown
Member Author

@MelvinBot could you check backend/OldDot parity with the current implementation in this PR? For the "Admin of some" (mixed) case, does Classic send an email to the owners of the workspaces you don't administer when skipping, or does it match this PR's current behavior of sending no email at all?

@MelvinBot

Copy link
Copy Markdown
Contributor

Classic sends the email. In the "admin of some" (mixed) case, Classic emails the owner (with the other admins in cc) of every missing workspace you don't administer, whether you invite now or do it later. It has no way to suppress that — "do it later" issues the same plain SetVacationDelegate(overridePolicyDiffWarning: true) call, and that path always queues those emails. So this PR's mixed Skipsubmit(true)skipPolicyInviteEmails: trueno email at all is a divergence from Classic, and it contradicts your own copy in en.ts:3819-3820 ("Admins of the workspaces you don't manage will be asked to add them").

One more signal that Skip is over-reaching: the backend only ever emails workspaces where you are not an admin. In the "admin of all" case nonAdminPolicies is empty, so there is nothing to suppress and skipPolicyInviteEmails: true is a no-op. The only case where the flag changes observable behavior is the mixed case — exactly the case where Classic sends the emails.

Suggested change: use submit(false) for Skip in the mixed case (keeps Classic parity and matches the copy), leaving submit(true) for the admin-of-all case. That's a one-line change gated on nonAdminPolicies.length === 0.

Caveat before you change it: the FE issue's own spec table says Skip → emails "none", so the divergence may be intentional. dannymcclain read it the other way in #99078 ("the Admins will be notified regardless… Invite and Skip really only apply to workspaces that you're already an admin of"), and trjExpensify never answered your question there. Worth getting an explicit call from him before merging, since the answer also decides whether skipPolicyInviteEmails has any purpose at all.

How I verified this
  • Classic behavior comes from the internal design issue this FE issue hangs off, which documents all three Classic cases. For the mixed case it states: for the workspaces you're an admin of you can invite now or do it later; for the ones you aren't an admin of, the admins of those workspaces get an email. The "no email is sent to the other workspace admins" line that mountiny quoted in [Vacation Delegate Invite] [FE] Replace the policy diff warning modal with an invite step page in the RHP #99078 belongs to the admin-of-all case, not the mixed one — and in that case there are no non-admin workspaces to email anyway, so it isn't evidence about mixed.
  • Backend email rule (also written up in the body of [Vacation Delegate Invite] [FE] Replace the policy diff warning modal with an invite step page in the RHP #99078): on the override path the backend emails one owner per missing workspace where you are not an admin, other admins in cc, and only where the vacationer is in that workspace's approval workflow. Workspaces you administer never get an email.
  • skipPolicyInviteEmails semantics: it is brand new — SetVacationDelegateParams on main has only overridePolicyDiffWarning. The internal backend issue specifies that when it's true the delegate still gets their own email but all invite-request emails to workspace owners and admins are skipped — i.e. it is not scoped to your admin workspaces. The Auth side deployed to production on 2026-08-31.
  • Current PR state at d6a29c2b: submit(shouldSkipPolicyInviteEmails) is called with true from the single Skip button shared by the admin-of-all and mixed variations, and with false from Confirm and from the end of the Invite loop.

I could not read the Auth/PHP implementations directly in this run (no access to those repos), so the "not scoped to admin workspaces" claim rests on the written backend spec rather than on the merged code. If you want that nailed down, ask rayane-d to confirm against the merged Auth change. I also didn't exercise the flow live — reaching the mixed 305 needs a delegate missing from a specific mix of admin/non-admin workspaces, which isn't reproducible on the test account.


view run

…tic delegate when request rejects, disable invite until every admin policy that is missing is loaded, foramt sms delegate logins as phone numbers, add selectors to onyx subscriptions, switch setDomainVacationDelegate to API.write
@jakubstec

Copy link
Copy Markdown
Member Author

@MelvinBot could you re-review it?

@MelvinBot

Copy link
Copy Markdown
Contributor

Re-review at d677b48e

The three commits since my last pass are solid: HTML-escaping the delegate before RenderHTML, the !!policyDiff gate that closes Codex's rollback finding, offline-blocking the footer, double-press guards, and the API.write ordering fix. Verified green: 20/20 tests pass across the three new files, npm run typecheck clean repo-wide, lint clean on all 32 changed files, React Compiler compliance passes. Only Check independent approval is red (ignorable).

Two things to settle before merge, then some pre-existing bugs this flow now walks straight into.

1. Mixed-case Skip is still unresolved (blocking on a product call)

Unchanged at VacationDelegateMissingWorkspacesPage.tsx:210 — Codex flagged the same thing independently. Per my parity check above, Classic does email the owners of the workspaces you don't administer when you skip, so submit(true) in the mixed case diverges from Classic and from your own copy. Needs trjExpensify's call, not a code change from you yet.

2. Please drop the closure-dependent Onyx selector

VacationDelegateMissingWorkspacesPage.tsx:57-60 subscribes to the whole REPORT_ACTIONS collection through an inline selector that closes over allReports. It is correct — I traced hasSelectorChanged in useOnyx, so no stale data — but it re-scans the entire collection every time allReports changes identity, and policyExpenseChatReportsSelector returns full Report objects, so that happens on every message and read receipt in any policy expense chat — exactly what the ExcludedFields comment 10 lines below it warns about.

The fix is also a simplification: subscribe to COLLECTION.REPORT and COLLECTION.REPORT_ACTIONS raw and call getAllPolicyExpenseChatReportActions(allReports, allReportActions) inside invite(). That's what WorkspaceInviteMessageComponent.tsx:184 — the only other caller of addMembersToWorkspace — does, and all 5 users of that util follow the same shape. It also lets you delete the new selector, which is currently used by this page alone.

3. Two pre-existing bugs the new dismissal path makes easy to hit

Both exist on main today, so not regressions — but this PR adds a new exit path that lands on them, and each is a one-liner. Your call whether to fix here or file follow-ups.

a. Dismissing the RHP can resurrect a deleted delegate. deleteVacationDelegate's successData never clears previousDelegate, and the next setVacationDelegate can't clear it either because it writes previousDelegate: currentDelegate and Onyx's merge skips undefined source properties. So: remove delegate A → select B → get a 305 → back out → clearVacationDelegateError('A') sets delegate: 'A' while the server has none. Fix: previousDelegate: null in the delete successData, or currentDelegate ?? null.

b. A failed final write leaves an unsaved delegate on screen. failureData writes the error but doesn't restore delegate, unlike deleteVacationDelegate's failureData. Status page then shows the new delegate with a dismissible red dot; if the user never taps the X they believe it saved. Adding delegate: currentDelegate makes the optimistic update self-healing.

Nits, and what I checked

Nits

  • VacationDelegatePage.tsx:55-59 navigates from the .then of an in-flight request with no focus check. Select a delegate, immediately press the header back button, and when the 305 lands the RHP force-pushes over whatever the user is now looking at. isSelectingRef doesn't cover this.
  • useOnyx at line 54 ignores the loading metadata, so a cold deeplink/refresh onto this route renders one frame of NotFoundPage before Onyx hydrates — and usePreventRemove is disarmed for that frame. Seeding submittedInput once policyDiff first becomes truthy would close it.
  • invite() doesn't set isSubmittingRef.current = true before its addMembersToWorkspace loop (line 116). Harmless today because the whole handler is synchronous, but if that loop ever throws, partial invites are sent with no delegate write and the ref stays false.
  • The page omits addMembersToWorkspace's optional 9th approverEmail arg that the invite page passes. Intentional?
  • The new UI test emits a lot of The action 'REPLACE' ... was not handled by any navigator console noise from goBackToStatus, if you care about clean output.

What I verified

  • Every symbol and translation key the new page uses resolves with a matching signature — the 8-arg addMembersToWorkspace call is arg-for-arg identical to the invite page's, getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs, formatPhoneNumber) order is right, and Str.htmlEncode before an HTML translation has direct precedent in ConciergeAutoMatchVendorContent.tsx.
  • Codex's first P2 (direct-route visit wiping a saved delegate) is fixed: usePreventRemove's preventRemove: false path returns before invoking the callback, so clearVacationDelegateError(undefined) is unreachable without a policyDiff. Re-entrancy is also closed upstream by react-navigation's visited-route-keys stamp.
  • Double-press orderings (Skip×2, Confirm×2, Invite→Skip, Skip→Invite) are all genuinely guarded; the API.write switch really does keep SetVacationDelegate behind the invites in the sequential queue.
  • Offline is properly blocked on both pages via FullPageOfflineBlockingView; the goes-offline-mid-request error modal is legitimate, only the copy is generic.
  • Untested paths, if you want more coverage: no-previous-delegate rollback, deleteVacationDelegate (no test at all), RHP double-press, failureData contents, and offline. The RHP test file stubs usePreventRemove wholesale, so dismissal assertions validate the callback body rather than the navigation contract.

Live test: failed on session infrastructure, not on your code. The browser tab went fully blank (0 DOM nodes) before the tester took control and never recovered; the dev server itself was healthy (HTTP 200, clean build). Console logs aren't obtainable on this web session, so I can't distinguish a headless-tab crash from a client error — but nothing here is evidence against the PR. The mixed 305 path still isn't reachable on a test account anyway.

Step Status Observation
Navigate to Settings > Profile > Status > Vacation delegate, no console errors fail Render tree empty (0 nodes), screenshot pure white; dev server returned 200 with valid HTML and a clean webpack build, so the blockage was client/session-side
List offers a selectable person, or shows the cannot-set message fail Settings/Status never reachable
Selecting a delegate doesn't flash a not-found page and saves fail Not reachable
No JS console errors throughout fail Not obtainable — log access unsupported for this web session
Evidence (3)

Last known good state: CI prestart reached the sign-up screen at 11:14 UTC

Last known good state: CI prestart reached the sign-up screen at 11:14 UTC

Blank white page when the tester took over at 11:30 UTC

Blank white page when the tester took over at 11:30 UTC

Still blank after waits and a probe press

Still blank after waits and a probe press

view run

…isting selector instead of newly created policyExpenseChatReportsSelector, build the policy expense chat report actions at invite time instead of in a per render useOnyx selector, remove no-op guard from delete branch, skip setVacationDelegate response navigation when the screen has lost focus, show loading indicator while vacation delegate is still hydrating
Comment thread src/libs/API/types.ts
Comment thread src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx
Comment thread src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx Outdated
Comment thread src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx Outdated
Comment thread src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx Outdated
Comment thread src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx Outdated
@jakubstec
jakubstec marked this pull request as ready for review September 7, 2026 07:42
@jakubstec
jakubstec requested review from a team as code owners September 7, 2026 07:42
@mountiny

mountiny commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

@mkhutornyi can you also do a full review please? thanks

@jakubstec

jakubstec commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Bug?: delegate is shown as saved with error
Repro step:
While in vacation delegate selection, quite app and reopen

It matches pre-existing behavior, so I assume it's not a bug - you have chosen a delegate, but invites and emails haven't been sent so I think that's intentional

It's weird to me that RBR shows here. Is it bug or expected?

As said above, it matches the pre-existing behavior

Screen.Recording.2026-09-09.at.16.02.13.mov

@trjExpensify

Copy link
Copy Markdown
Contributor

I've never seen that before, I can't see it in the OG design doc either. I don't think it really makes sense either for a few reasons:

  1. These changes improve the clarity of which policies they are or aren't a member of at the time of setting the delegate
  2. It could be intentional. They want the delegate on one policy, but not another.
  3. The account holder might not have the power to invite the delegate to the missing policies, so they can't do anything about this persistent error.

…acesPage component, change the copy message, minor performance issues, code clean-up
@jakubstec

Copy link
Copy Markdown
Member Author

@mkhutornyi I've addressed all the comments - could you verify them?

@jakubstec

Copy link
Copy Markdown
Member Author

kindly bumping! @mkhutornyi

@mkhutornyi

mkhutornyi commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
Android: mWeb Chrome
iOS: HybridApp
iOS: mWeb Safari
MacOS: Chrome / Safari
Screen.Recording.2026-09-14.at.6.21.33.PM.mov
Screen.Recording.2026-09-14.at.6.23.17.PM.mov
Screenshot 2026-09-07 at 8 08 20 PM Screenshot 2026-09-07 at 8 08 29 PM Screenshot 2026-09-07 at 8 08 49 PM

@mkhutornyi

Copy link
Copy Markdown
Contributor

Please pull main and fix lint

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8b54c271ff

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/libs/actions/VacationDelegate.ts
@MelvinBot

Copy link
Copy Markdown
Contributor

Re-review at 8b54c271

Good news first: the two things I flagged before are resolved. skipPolicyInviteEmails is gone entirely (commit 457fd18), so the mixed-case Skip now sends the same plain SetVacationDelegate(overridePolicyDiffWarning: true) Classic sends — parity restored and the copy is no longer contradicted. The copy also picked up shawnborton/trjExpensify's "the workspaces below" wording, and all 11 locales carry all 5 new keys.

Two things left. The first answers mkhutornyi's lint request — and it is not a stale-main problem.

1. The ESLint failure is caused by this PR — pulling main won't fix it

This PR added a return to clearVacationDelegateError at VacationDelegate.ts:162. That makes it Promise-returning, so StatusPage.tsx:261 passing () => clearVacationDelegateError(…) into onCloseError: () => void is now a real @typescript-eslint/no-misused-promises error. CI points at exactly that line: src/pages/settings/Profile/CustomStatus/StatusPage.tsx:261:46.

Suggested fix: drop the return. Nothing consumes the promise — not the four call sites, not either test — and the sibling Domain.clearVacationDelegateError still returns void, which is why DomainMemberDetailsPage.tsx:174 doesn't trip the same rule. Reverting the return restores parity and is a one-line change.

2. Selecting a delegate while offline now hard-errors and reverts

The error handling was rewritten from if (!response?.jsonCode) { goBack } to if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { showErrorModal }, plus a new .catch() at VacationDelegatePage.tsx:79-86. The non-override path still goes through makeRequestWithSideEffects, which is not persisted and has no offline short-circuit — processWithMiddleware calls HttpUtils.xhr directly, and the Logging middleware rethrows a failed fetch rather than swallowing it.

So offline, picking a delegate rejects, hits the new .catch(), shows "There was an error updating your vacation delegate." and calls clearVacationDelegateError(currentDelegate) — reverting the selection. The page also has shouldShowOfflineIndicator={false}, so there's no hint the user is offline first.

That contradicts commit 671cd1c's own stated intent ("allow to optimistically set a vacation delegate") and the PR's Offline tests: N/A. Please test this case. If offline selection is meant to work, the reject/no-jsonCode branch needs to keep the optimistic delegate (or the whole call needs to be a persisted write) rather than roll it back.

Nit + what I verified

Nit — stale persisted policyDiff. policyDiff is new client-only state on the persisted NVP, cleared only by setVacationDelegate, clearVacationDelegateError, or the RHP's usePreventRemove handler. Kill the app with the RHP open — mkhutornyi's exact repro — and it survives the restart alongside an optimistic delegate the server never accepted, making VacationDelegatePage.tsx:54 hasUnconfirmedChange indefinitely. The unsaved-delegate half is pre-existing, but the never-cleaned policyDiff is new. Worth confirming it doesn't leave the flow reachable forever.

Nit — disabled Invite has no explanation. When an admin workspace hasn't loaded, hasUnresolvedAdminPolicy disables Invite with no message; the only signal is the "Unavailable" row title. Low priority.

Verified good: the three-way copy branch in MissingWorkspacesIntro maps correctly onto test steps 2–4, including that notAMemberInviteThemNow (admin-only) rightly omits the email sentence since nonAdminPolicies is empty there. Str.htmlEncode before RenderHTML is still in place. createPoliciesByIDsSelector moving into src/selectors/Policy.ts with its own test is a good cleanup — I'm dropping my earlier selector concern; React Compiler Compliance is green on the file. The invite ordering comment at VacationDelegate.ts:214 and the isLastInvite personal-details gating are sound. Deeplinking straight to the RHP without a diff correctly renders NotFoundPage.

CI: all 8 test jobs, typecheck, React Compiler Compliance, Bun tests, spellcheck, and both perf jobs pass. ESLint check fails for the reason above. checklist and Check independent approval are the ignorable ones.

Caveat: I could not drive this in a browser on this run (web prep failed), and this workspace's node_modules is incomplete, so I couldn't run ESLint or Jest locally. Findings 1 and 2 come from reading the code plus the CI log; finding 1 is pinned to the exact line CI reports.


Next Steps: Reply with @MelvinBot <your feedback> to refine this review.

@MelvinBot

Copy link
Copy Markdown
Contributor

Code quality review at 8b54c271

Quality is good overall — the new page splits cleanly into four small presentational components, the comments explain why (the 305/failureData note and the invite-ordering note are genuinely useful), the ESLint disables are each justified inline, and the tests use named constants instead of magic strings. No AI jargon, no magic values, UI-1/UI-2 satisfied. Four things worth tightening, none blocking.

1. Undocumented props next to documented siblings (CONSISTENCY-10)

Each of these types documents some props and skips others, which reads like an oversight rather than a choice:

Location Missing /** */
WorkspaceRow.tsx:15 policyID
WorkspaceSection.tsx:15,20 title, policies
MissingWorkspacesFooter.tsx:18 onInvite
VacationDelegate.ts:181-182 translate, formatPhoneNumber

Separately, SetVacationDelegateOptions documents nothing while its sibling InviteVacationDelegateToWorkspacesOptions twelve lines below documents most fields. shouldOverridePolicyDiffWarning in particular carries the whole persisted-write-vs-side-effect branch and deserves a line.

2. Collapse submit / submitOnce / invite into one guarded function

index.tsx:83-119 is three functions where one does the job. invite re-implements the isSubmittingRef guard rather than reusing submitOnce, then calls the unguarded submit — so the guard lives in two places and the reader has to check both to convince themselves there's no double-submit path.

Taking the policies to invite as an argument removes the duplication and the mechanism-named submitOnce:

const submit = (policiesToInvite?: Policy[]) => {
    if (isSubmittingRef.current) {
        return;
    }
    isSubmittingRef.current = true;

    if (policiesToInvite) {
        inviteVacationDelegateToWorkspaces({delegate, policies: policiesToInvite, inviter: {...}, translate, formatPhoneNumber});
    }

    setSubmittedInput({delegate, policyDiff});
    setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true});
    Navigation.goBack(ROUTES.SETTINGS_STATUS);
};

Then onSkip={() => submit()} and onInvite={() => submit(adminWorkspaces)}. The hasUnresolvedAdminPolicy re-check inside invite can go too — the button is already disabled by isInviteDisabled from the same value.

3. onSkip drives the Confirm button too

Its own JSDoc admits the mismatch: "backing both Skip and Confirm". A prop whose name is wrong for half its call sites is worth renaming — onSubmitWithoutInvite or onConfirm says what it does in both branches. See MissingWorkspacesFooter.tsx:20-21.

4. The policy-key lookup is written twice (CONSISTENCY-3)

The same policies?.[POLICY_COLLECTION_KEY + policyID] template lookup appears at both index.tsx:60 and WorkspaceSection.tsx:40. The parent already resolves the collection for adminWorkspaces, so drilling the raw collection down two levels just so the child can resolve it again is avoidable — resolve once in the parent and give WorkspaceSection the resolved {policyID, policy} entries. That also drops the ONYXKEYS/OnyxCollection imports from the child and makes it purely presentational.

Considered and deliberately not flagged
  • createPoliciesByIDsSelector returning whole Policy objects — looks like a PERF-19 candidate, but isn't: adminWorkspaces is passed intact into inviteVacationDelegateToWorkspacesaddMembersToWorkspace, which reads employeeList and name. The rule only applies when nothing the component keeps from the selector output is an object. Moving it to src/selectors/Policy.ts with its own test was the right call.
  • Missing file headers on the four new sub-components (CONSISTENCY-14) — they're 45-65 lines of self-explanatory presentational code with named exports, which the rule's "short and self explanatory" exemption covers. index.tsx has the header that matters.
  • FullScreenLoadingIndicator shouldUseGoBackButton — correct per UI-1: it's in a separate return with no header alongside it.
  • onSelectRow in VacationDelegatePageCONSISTENCY-12 would prefer a behavior name, but it's a pre-existing name that mirrors the prop it feeds.
  • Comment density in setVacationDelegate — high, but every block explains a non-obvious backend or queueing behavior rather than restating the code.

This pass was static only — I couldn't drive the app in a browser on this run, and this workspace's node_modules is incomplete so I couldn't run ESLint locally either. Everything above comes from reading the code against App/.claude/skills/coding-standards. My separate correctness findings are in the earlier comment and still stand.


Next Steps: Reply with @MelvinBot <your feedback> to refine this review.

@jakubstec

Copy link
Copy Markdown
Member Author

done! @mkhutornyi

@mkhutornyi

Copy link
Copy Markdown
Contributor

Please check this weird behavior.

Abandoning the missing-workspaces step leaves a delegate that reads as saved

  1. Have no vacation delegate set, and at least one workspace the delegate isn't a member of.
  2. Go to Settings > Profile > Status > Vacation delegate and pick that person. The missing-workspaces step opens.
  3. Kill the app (or close the browser tab).
  4. Reopen the app and go to Settings > Profile > Status.
  5. The delegate is listed as saved - no pending state, no error, no brick road on Profile.
  6. As it was never saved server-side, error happens when try to remove this delegate. Can be removed only after clear cache and restart.
Screen.Recording.2026-09-11.at.3.31.20.PM.mov

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b39f0deea

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx
@MelvinBot

Copy link
Copy Markdown
Contributor

Regression hunt at 9b39f0de

The happy path is clean — I drove Settings > Status > Vacation delegate in the browser and set, saved, and cleared a delegate with no errors and no stray red brick road. ESLint is green now, so that finding is closed.

Four things this PR changes outside the flow it documents. None are caught by the PR's test steps or screenshots, which only cover Settings > Status.

1. The Domain member page silently inherits the new delete behavior

shouldHideOnDelete={false} was added inside the shared VacationDelegateMenuItem.tsx:60, and that component has two consumers — StatusPage.tsx:257 and DomainMemberDetailsPage.tsx:168.

Per OfflineWithFeedback.tsx:98, hideChildren requires shouldHideOnDelete. So clearing a domain member's vacation delegate now leaves the row on screen during the pending delete, where it used to hide. That's most likely the correct fix there too — it's the same disappearing-row bug 32a13a3 fixed for Status — but it's a second feature changing behavior with no test step and no screenshot. Please exercise Domain > Members > member > Vacation delegate > clear before merging.

2. Offline delegate selection still errors out and reverts

Unchanged from my last review — VacationDelegatePage.tsx:79-86. The non-override path uses makeRequestWithSideEffects, which isn't persisted and has no offline short-circuit, so the fetch rejects, the new .catch() fires, and the selection is rolled back behind an error modal. The old code treated a missing jsonCode as success instead. Still needs a decision.

3. 9b39f0de dropped the unresolved-workspace guard from the submit handler

Before this commit, invite() guarded on if (isSubmittingRef.current || hasUnresolvedAdminPolicy) return;. The merged submit() at index.tsx:83-88 now only checks isSubmittingRef. The sole remaining guard is isInviteDisabled on the Button.

That matters because adminWorkspaces is already filtered to the loaded subset, so if the disabled state is ever bypassed or races a policy unloading, Invite saves the delegate and quietly invites only some of the listed workspaces. The comment right above it at line 59 still promises the opposite — "a missing one has to block the invite rather than be dropped from it". Cheap to restore inside submit().

4. The brick-road gate reaches a third surface

getVacationDelegateErrors was wired into UserUtils.getProfilePageBrickRoadIndicator, which is also consumed by useInitialSettingsPageMenuData.ts:238 — the Settings/Account menu indicator, not just the Profile page row the PR mentions. Any vacation delegate error is now suppressed there too for as long as pendingAction is set, which offline means the entire time the write sits queued. Worth a conscious sign-off rather than a side effect.

Browser verification

Ran on the PR head. Steps 5–7 (reload persistence and direct deep-links to the two vacation-delegate routes) could not be run — this session cannot reload or navigate by URL without risking an unrecoverable auth wall. Those are exactly where the new persisted policyDiff and the new nested route would show problems, so they still need a manual pass.

Status page after clearing delegate; Vacation delegate section still rendered

Step Status
Status page renders the Vacation delegate section and rowHeading, description and a tappable row all present.
Status page with Vacation delegate section and empty row
Selecting a user saves and returns to Status with the delegate shownPicked a user, navigated back automatically, row shows the delegate. POST /api/UpdateStatus returned 200.
Status page showing Test Person as the selected vacation delegate
Profile Status row shows no error dot after a successful saveNo red indicator on the Status row or the Account tab icon.
Profile page, Status row with no error indicator
Re-tapping the same delegate clears it and the row stays renderedDelegate cleared and the Vacation delegate section is still present — the 32a13a3 fix holds.
Status page after clearing delegate; Vacation delegate section still rendered
Reload, then check the delegate row and error stateNot run — this session cannot reload without risking an unrecoverable auth wall.
⚠️
Deep-link to settings/profile/status/vacation-delegateNot run — direct URL navigation is unavailable in this session.
⚠️
Deep-link to settings/profile/status/vacation-delegate/missing-workspacesNot run — direct URL navigation is unavailable in this session.
⚠️
Checked and cleared
  • SetVacationDelegate in both WRITE_COMMANDS and SIDE_EFFECT_REQUEST_COMMANDS — not a problem. CompleteGuidedSetup is already registered both ways, so this is an established pattern.
  • Domain.setDomainVacationDelegate switching from makeRequestWithSideEffects to API.write — the response was never read (the old comment said so), the request becomes persisted and replayable offline, and tests/actions/DomainTest.ts was updated to match. Reads as a straight improvement.
  • The new nested route settings/profile/status/vacation-delegate/missing-workspaces is registered exact: true alongside the parent, so it doesn't shadow SETTINGS_VACATION_DELEGATE. Deep-linking it with no diff renders NotFoundPage by design — I just couldn't confirm that in a browser.
  • Domain page error display is unaffected by the new gate; it passes getLatestError(domainErrors…) directly rather than going through getVacationDelegateErrors.
  • CI at this head: ESLint, typecheck, React Compiler Compliance, Bun tests, all 8 test jobs, and both perf jobs pass. Only checklist is red.

Next Steps: Reply with @MelvinBot <your feedback> to refine this.

@jakubstec
jakubstec force-pushed the jakubstec/policy-diff-warning-rhp-vacation-delegate branch from 9b39f0d to 35cac1f Compare September 14, 2026 09:16
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 35cac1ff22

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@jakubstec

Copy link
Copy Markdown
Member Author

@mkhutornyi should be ready!

invitedEmailsToAccountIDs,
// Writes resolve in queue order, so only the last invitation may clean the optimistic delegate up from under the ones still in flight.
isLastInvite ? personalDetailsOnyxData : {optimisticData: personalDetailsOnyxData.optimisticData},
`# ${inviter.displayName ?? ''} invited you to ${policy.name}\n\n${translate('workspace.common.welcomeNote')}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The invite message's heading is hardcoded English.
Only the second half goes through translate

Comment on lines +56 to +61
setVacationDelegate({creator: currentUserLogin, delegate: option?.login ?? '', currentDelegate})
.then((response) => {
if (!navigation.isFocused()) {
if (response?.data?.policyDiff) {
clearVacationDelegateError(currentDelegate);
}

@mkhutornyi mkhutornyi Sep 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is expected: leaving the selector before the response lands silently discards the pick

On main the policy-diff prompt was a global confirm modal, so it appeared over whatever screen you were on and Confirm still saved the delegate. Now the RHP push is gated on navigation.isFocused(), and the else-branch just calls clearVacationDelegateError() and returns — no step, no modal, no error. Same in .catch at L79.

Steps

  1. Have a workspace the delegate isn't a member of.
  2. Settings > Profile > Status > Vacation delegate.
  3. Tap a user who isn't a member of that workspace.
  4. Immediately tap the header back arrow, before the request returns.
  5. Check the vacation delegate row on Settings > Profile > Status.

main: the prompt is shown so the choice can still be completed.
this branch: the row shows the previous delegate (or stays empty) — the pick is dropped with nothing shown anywhere.

Edge case though, not blocker

Screen.Recording.2026-09-14.at.6.17.48.PM.mov

@jakubstec jakubstec Sep 14, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is because we are waiting for a response with side effects to come back with policyDiff; that's why you see it flickering for a while. By removing a modal and using RHP here just it just widens a window when we can have this race. I can get the same bug on main

Screen.Recording.2026-09-14.at.19.57.35.mov

@MelvinBot

MelvinBot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Found 4 regressions — behavior that worked on main and is worse here. I also ran the flow on web against the live API; it passed end to end, so none of these are crashes.

1. The RHP shows the raw email instead of the delegate's display name

MissingWorkspacesIntro.tsx:26 renders vacationDelegate.pendingDelegate, which is always a login. The old warning modal was passed option.text — the name built by getVacationDelegateDisplayName — and rendered displayName ?? login.

Assign "Jane Doe" (jane@acme.com): before you got "You're assigning Jane Doe…", now you get "jane@acme.com is not a member of the workspaces below…". Visible in my run:

Missing-workspaces RHP showing the raw email instead of a display name

This is also the one case from the deleted test file that isn't replaced — see #4.

2. Picking a delegate now blocks behind the entire write queue

VacationDelegate.ts:94 awaits API.waitForWrites, which is waitForSequentialQueueIdle() — it drains all persisted writes, not just SetVacationDelegate. Because the await sits before makeRequestWithSideEffects, the optimistic Onyx write is deferred too.

Send 20 messages offline, reconnect, then open Vacation delegate and tap someone: no checkmark, no spinner, nothing, for as long as the unrelated queue takes — and every further tap is swallowed by the isSelectingRef guard. On main the selection applied instantly. The code comment only justifies serializing against a concurrent SetVacationDelegate; the barrier used is much wider than that.

3. A 305 without data.policyDiff is an unrecoverable dead end

VacationDelegate.ts:102 gates the RHP on response.data?.policyDiff; anything else falls to Onyx.update(failureData) at line 114. On main any 305 opened the warning modal, and Confirm set overridePolicyDiffWarning: true. Now there is no other way to reach the override, so such a user sees the generic error plus an RBR on Profile and can never assign that delegate.

policyDiff is a new field on Response (Response.ts:17), so this is a hard, unfallbacked backend dependency. My live run did get the RHP, so Auth is sending it today — this is a resilience gap, not a live break. Worth a fallback to the old proceed-anyway prompt when policyDiff is absent.

4. Dropped test coverage

tests/unit/pages/.../VacationDelegatePageTest.tsx was deleted. Three of its four cases are covered by the new UI tests; "uses the display name for an email account" is not — that's regression #1. Separately, nothing in the new tests asserts the showErrorModal path or that an EXP_ERROR message is surfaced — exactly the branch #3 lands in.

Lower severity, speculative, and what I checked and cleared

LOW — shouldHideOnDelete={false} also changes the Domain member page. VacationDelegateMenuItem.tsx:60 is shared with DomainMemberDetailsPage. A domain admin deleting a member's delegate online now sees a greyed-out empty field instead of the row disappearing. Consistent with the new intent and unit-tested, but not in the PR's test steps.

SPECULATIVE — offline desync. The override path is now a persisted write. Offline: Skip queues SetVacationDelegate(B) → reopen the page and tap C → side-effect request rejects → dismiss the modal → clearVacationDelegateError reverts the NVP to A. On reconnect B saves server-side, but its successData only nulls flags and never re-asserts delegate: B. UI shows A, server has B until refresh. Can't happen on main, where no SetVacationDelegate was ever persisted. Needs a real offline repro.

SPECULATIVE — a never-settling request bricks selection. isSelectingRef is only cleared in .finally(), and HttpUtils has no per-request timeout. A hung connection makes every subsequent tap a silent no-op until the RHP is closed and reopened.

Checked, no regression found: Domain.ts (side-effect → API.write swap is a strict improvement — overridePolicyDiffWarning was already hard-coded true and the return value was unused), UserUtils.ts (isEmptyObject(undefined) is true, so the no-error path is byte-identical), selectors/Policy.ts (correct, and cheaper than its sibling selector), all Navigation/deeplink registration (new screen registered in the same five places as VACATION_DELEGATE, both exact: true, cold deeplink renders NotFoundPage), StatusPage.tsx/ProfilePage.tsx, all 11 locale files, and API/types.ts (the command appearing in both WRITE_COMMANDS and SIDE_EFFECT_REQUEST_COMMANDS is safe — dispatch reads apiRequestType, not the command name).

One thing that got better: on main the rejected side-effect promise had no .catch(), leaving the optimistic delegate and pendingAction: UPDATE stuck with no dismissible error. The new .catch() surfaces an error and rolls back.

Runtime verification (web, dev NewDot, this branch). Profile and Status pages load, the delegate list and search render, selecting a user with no policy diff saves directly, selecting one missing from an admin workspace opens the new RHP, and dismissing it with back correctly leaves the previously saved delegate in place. No red-box errors. Direct JS console capture isn't supported on this harness.


view run · no recording available

@mkhutornyi

Copy link
Copy Markdown
Contributor

@MelvinBot are those regressions caused by this PR, not happening on main?

@MelvinBot

MelvinBot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Yes — all four are PR-caused. I re-checked each one by diffing base f777fabb against head 35cac1ff, not just reading the new code. Here's the before/after for each.

One correction first: the screenshot I attached to #1 was not proof. rory@expensifail.com has no display name in that account, and main falls back to the login in that case too — so main would have rendered the same thing. The code and the deleted test are the evidence; the screenshot isn't.

1. Display name → raw login

main VacationDelegatePage.tsx:84 passed option.text into the modal, and line 47 rendered Str.removeSMSDomain(delegateDisplayName ?? delegateLogin). The PR renders formatPhoneNumber(pendingDelegate) (MissingWorkspacesIntro.tsx:26), and pendingDelegate is always a login.

The strongest evidence is the test this PR deleted, which asserted exactly this:

await selectRowAndFlush(EMAIL_DELEGATE, 'Jane Doe');
expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: 'statusPage.vacationDelegateWarning(Jane Doe)'}));

So on main a delegate with a display name showed "Jane Doe". Delegates without one showed the login on both branches — that part is unchanged.

2. Selection blocked behind the write queue

main's setVacationDelegate was not async and had no barrier — it went straight to makeRequestWithSideEffects at line 60, so prepareRequest applied optimisticData synchronously on tap. The PR inserts await API.waitForWrites(...) at VacationDelegate.ts:94 before that call, so both the optimistic write and the request now wait for the whole sequential queue to drain.

Confirmed by code only — I didn't build an offline backlog and reproduce the visible stall.

3. 305 without policyDiff

main VacationDelegatePage.tsx:83-84 gated on jsonCode alone and never read the payload; Confirm then called setVacationDelegate(..., true, ...) at line 54. The PR adds && response.data?.policyDiff (VacationDelegate.ts:102) and routes the else to failureData, and the override is now reachable only through the RHP. That narrowing is real and PR-introduced.

As I said before, Auth does send policyDiff today — my earlier run got the RHP — so this is a missing fallback rather than a live break.

4. Dropped test coverage

The PR deletes the file, so yes. Three of its four cases are covered by the new UI tests; uses the display name for an email account in the warning prompt is the one that isn't, because the behavior it asserted is gone (#1).

5. shouldHideOnDelete (the LOW one)

main's VacationDelegateMenuItem passed no shouldHideOnDelete, and OfflineWithFeedback defaults it to true (line 78). The PR sets it to false (line 60), so an online delete keeps the row visible instead of hiding it — on the Domain member details page too, which shares the component.

The two items I labelled speculative both hang off code this PR introduced (the persisted override write, and the new isSelectingRef guard), so they can't happen on main either — but I haven't reproduced either one, so treat them as unproven rather than confirmed.


view run · no recording available

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 35cac1ff22

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createPoliciesByIDsSelector([...adminPolicies, ...nonAdminPolicies])});

// Only loaded workspaces can be invited into, so a missing one has to block the invite rather than be dropped from it.
const adminWorkspaces = adminPolicies.map((policyID) => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]).filter((policy): policy is Policy => !!policy?.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require fully loaded policies before enabling Invite

When an admin policy exists only as an Onyx summary (isFromFullPolicy is false) or otherwise lacks a complete employeeList, this filter treats it as resolved solely because it has an id. Invite is then enabled and the incomplete membership is passed through getMemberAccountIDsForWorkspace() to addMembersToWorkspace(), which uses those account IDs to construct reportCreationData and decide whether to create the #announce room; this can omit existing members or fail to create the room when the invitation crosses the three-member threshold. Require or fetch the full policies before considering these workspaces invitable.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants