You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
IOU - "Tag no longer valid" error is not displayed until refresh the page #101584
Precondition:
As the admin of the policy enables categories/tags
Navigate to Rules > Require fields for all expenses
Enable category/tag toggle and Save
Add three tags (Tag 1, Tag 2, Tag 3)
Invite employee
Steps:
Employee create expense using Tag 1
Admin delete Tag 1
Expected Result:
"Tag no longer valid" error should be displayed in real time
Actual Result:
"Tag no longer valid" error is not displayed until refresh the page
Transaction violations are only recalculated in the one client that performs the tag deletion, and only for transactions that client already happens to hold in Onyx. Nothing recalculates them when a tag-list change arrives from the server, so the employee's session (and the admin's session when it hasn't loaded the expense) never gets a tagOutOfPolicy violation until the next full refetch — which is exactly what a page refresh does.
There is no reactive recompute: every caller of getViolationsOnyxData is a user-initiated action, and no Onyx subscription or derived value recomputes violations when policyTags_ changes. The violations derived value only depends on transactions and violations themselves (src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts:13).
The UI is not at fault — the expense detail subscribes live via src/hooks/useTransactionViolations.ts:21, so an Onyx write would render immediately. The data simply never arrives.
What changes do you think we should make in order to solve the problem?
Make the violation recompute react to incoming tag-list changes, not just locally-initiated ones. When policyTags_<policyID> changes for any reason (including a server push), re-run ViolationsUtils.getViolationsOnyxData for the locally-held transactions of that policy and write the result to transactionViolations_<id>. That fixes the employee's session client-side and also closes the acting-client blind spot at ReportUtils.ts:2337.
One caveat for the reviewer: the alternative fix is for the backend to include transactionViolations_<id> in the DeletePolicyTags response or push it over Pusher to each affected submitter. If you decide that is the right layer, this needs an internal engineer — that code is outside the App repo and I can't propose changes to it.
What alternative solutions did you explore? (Optional)
Alternatives and supporting evidence
If the recording shows a Search page rather than the expense report, the cause is different. Inside SearchScopeProvider, useOnyx silently redirects transactionViolations_* reads to snapshot_<hash> (src/hooks/useOnyx.ts:74-99), so a live violation write does not re-render until the search re-runs. Several rows already work around this with useOnyxWithoutSnapshots; the same treatment would be the fix. ReportScreen and SearchMoneyRequestReportPage are not wrapped, so the expense-report surface is unaffected.
Ruled out: the optimistic math itself. Traced against this exact precondition (single tag list, 3 tags, delete Tag 1, requiresTag on) every gate passes and tagOutOfPolicy is pushed. Nothing skips tags with pendingAction: DELETE, and an existing test covers the equivalent enabled: false shape at tests/unit/ReportUtilsTest.ts:13057-13108. tests/actions/PolicyTagTest.ts has zero assertions on TRANSACTION_VIOLATIONS, so the end-to-end delete → violation path is only covered indirectly — worth adding a test alongside the fix.
Ruled out: stale UI or stale memoization on the report surfaces. The expense detail, transaction preview, and report transaction list all subscribe to TRANSACTION_VIOLATIONS reactively.
Minor inconsistency spotted nearby (not this bug, 3 tags exist here): deleteWorkspaceCategories flips requiresCategory: false optimistically when the last category is removed (src/libs/actions/Policy/Category.ts:1402-1409); deletePolicyTags never flips requiresTag.
What I could not verify: which account's screen the recording shows. That decides whether the primary gap is the admin's un-hydrated transaction or the employee's missing push — the fix above covers both. I also could not run a live repro: the test session handed to me failed during onboarding, so this analysis is code-evidence only.
Confidence: medium — mechanism is confirmed in code with no production log or runtime confirmation.
Related issues
No duplicates found. A quick scan of open Expensify/App issues for this symptom turned up nothing covering tag-violation staleness.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/101584) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
When a workspace tag is deleted or disabled (e.g. via deletePolicyTags or setWorkspaceTagEnabled in src/libs/actions/Policy/Tag.ts), only ONYXKEYS.COLLECTION.POLICY_TAGS is updated optimistically and on the wire.
Neither deletePolicyTags nor setWorkspaceTagEnabled re-evaluates or emits optimistic data for ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS for existing transactions in Onyx holding that tag.
Furthermore, transaction components (like MoneyRequestView) consume transactionViolations stored in Onyx. When server updates to policyTags arrive, no reactive listener triggers ViolationsUtils.getViolationsOnyxData for existing transactions.
Only when a page is hard-refreshed (triggering initial search/report fetches) or when a transaction field is edited does the client re-calculate violations, causing the "Tag no longer valid" error to remain hidden until refresh.
What changes do you think we should make in order to solve the problem?
In src/libs/actions/Policy/Tag.ts:
In deletePolicyTags and setWorkspaceTagEnabled, when tags are removed or disabled, look up loaded transactions for the given policyID and generate optimistic ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS updates using ViolationsUtils.getViolationsOnyxData.
In the transaction viewing flow (MoneyRequestView.tsx / useViolations):
Ensure tag violations reactively account for changes in the active policyTags list so that even when tag changes arrive via server push, existing transactions immediately reflect the tagOutOfPolicy state.
What alternative solutions did you explore? (Optional)
Relying exclusively on the backend to push updated transactionViolations for all transactions was considered, but backend push does not retroactively broadcast violations for all historical transactions upon tag deletion. Handling optimistic and reactive re-evaluation in App ensures immediate, consistent feedback across all platforms.
Taking that as correct — I can't read the backend repo, so I'm going on your word. It drops my "subscribe to policyTags_ and recompute violations" recommendation as the primary fix.
If the submitter already receives a transactionViolations_<id> push, two App-side gaps remain that each produce "only after refresh". Which one applies depends on whose screen the recording shows.
1. The viewer isn't a submitter (the admin who deleted the tag, or an approver watching the expense). No push targets them. Their only source of the violation is the optimistic recompute in the delete action, and that is scoped to transactions already hydrated in Onyx: usePolicyData builds reports/transactionsAndViolations from the local derived value (src/hooks/usePolicyData/index.ts:24), and pushTransactionViolationsOnyxData returns early when the policy has no locally-held transactions (src/libs/ReportUtils.ts:2328-2331). An admin deleting the tag from the Tags page without the employee's expense loaded writes nothing, so the error only shows after a refetch.
2. The viewer is in search scope (Reports/Expenses tab, or any surface under SearchScopeProvider). The push does land in transactionViolations_<id>, but in search scope useOnyx redirects that read to snapshot_<hash> (src/hooks/useOnyx.ts:76-100; transactionViolations_ is in the redirect list at src/CONST/runtimeDefaults.ts:91). Only locally-initiated actions mirror their updates into the snapshot (src/libs/actions/IOU/SearchUpdate.ts:152); a Pusher update from another user's action never does, so the snapshot stays stale until the search re-runs — which a refresh does. Fix shape here is reading violations via useOnyxWithoutSnapshots on the affected rows.
If the recording is instead the submitter on the Inbox expense view, neither applies — that surface isn't in search scope and MoneyRequestView subscribes to violations live — so the push isn't landing, and that's a delivery question outside the App repo.
Which session does the video show — the admin deleting the tag or the employee — and on which screen (Inbox report vs Reports/Expenses)? That picks between 1 and 2 and I'll revise the proposal to match. I couldn't confirm it myself: the frames aren't extractable in my environment, and a two-account repro was out of reach for this run.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/101584) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
If the submitter already receives a transactionViolations_ push, two App-side gaps remain that each produce "only after refresh". Which one applies depends on whose screen the recording shows.
@MelvinBot The problem is that useTransactionViolations doesn’t re-render when it receives a Pusher event with policyTags_.
Agreed — that localizes it, and the hook confirms it. Revised proposal below.
Proposal
What is the root cause of that problem?
useTransactionViolations has no policyTags_ dependency. It subscribes to the transaction, transactionViolations_, report_, policy_ and personal details (src/hooks/useTransactionViolations.ts:20-24) and memoizes on exactly those (src/hooks/useTransactionViolations.ts:44). A tag-list change arriving on its own therefore cannot add or clear tagOutOfPolicy on an open expense — the view keeps rendering the stored violations array until a refetch replaces it, which is what the refresh does.
What changes do you think we should make in order to solve the problem?
Subscribe to policyTags_<policyID> in the hook and re-derive the tag violations inside the same memo, right next to the existing syncCustomUnitRateOutOfDateRangeViolation call. That helper (src/libs/Violations/ViolationsUtils.ts:397) exists for this exact reason — a stored violation that can go stale against current policy data is re-derived at read time instead of written — so a sibling syncTagOutOfPolicyViolation(violations, transaction, policyTagList, policy) fits the file's existing shape. The rules to reuse are already there: single-level at src/libs/Violations/ViolationsUtils.ts:89, multi-level at src/libs/Violations/ViolationsUtils.ts:241. It should cover missingTag as well as tagOutOfPolicy, since deleting the last tag flips the list's "has enabled tags" state.
Why this shape:
Display-only, no Onyx write — it can't race the server value or the optimistic data from the delete.
Fixes every viewer at once — admin, approver or submitter, whoever the tag push reaches first; the hook has 16 call sites.
Search surfaces, and tests
This also covers the Reports/Expenses surfaces: policyTags_ is not in the snapshot redirect list (src/CONST/runtimeDefaults.ts:91 has policy_, which does not match policyTags_), so inside search scope the hook reads live tags even though transactionViolations_ still resolves from snapshot_<hash>.
tests/actions/PolicyTagTest.ts has no assertions on TRANSACTION_VIOLATIONS today. Worth adding unit coverage for the new helper: tag deleted → tagOutOfPolicy appears; tag re-added or re-enabled → clears; missingTag behaviour when the last enabled tag goes away.
Confidence: medium — code evidence only. I couldn't run a live repro this round; it needs two signed-in accounts.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/101584) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
Activity
MelvinBot commentedon Sep 18, 2026
Proposal
What is the root cause of that problem?
Transaction violations are only recalculated in the one client that performs the tag deletion, and only for transactions that client already happens to hold in Onyx. Nothing recalculates them when a tag-list change arrives from the server, so the employee's session (and the admin's session when it hasn't loaded the expense) never gets a
tagOutOfPolicyviolation until the next full refetch — which is exactly what a page refresh does.deletePolicyTagsmarks the tag{pendingAction: DELETE, enabled: false}and then recomputes violations optimistically atsrc/libs/actions/Policy/Tag.ts:488. That math is correct —enabled: falseis enough to tripsrc/libs/Violations/ViolationsUtils.ts:100and pushtagOutOfPolicy("Tag no longer valid",src/languages/en.ts:10682). The problem is scope, not logic:pushTransactionViolationsOnyxDatabails out entirely atsrc/libs/ReportUtils.ts:2337when the acting client has no locally-hydrated transactions for the policy — its input comes from local Onyx only (src/libs/ReportUtils.ts:2098).getViolationsOnyxDatais a user-initiated action, and no Onyx subscription or derived value recomputes violations whenpolicyTags_changes. The violations derived value only depends on transactions and violations themselves (src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts:13).The UI is not at fault — the expense detail subscribes live via
src/hooks/useTransactionViolations.ts:21, so an Onyx write would render immediately. The data simply never arrives.What changes do you think we should make in order to solve the problem?
Make the violation recompute react to incoming tag-list changes, not just locally-initiated ones. When
policyTags_<policyID>changes for any reason (including a server push), re-runViolationsUtils.getViolationsOnyxDatafor the locally-held transactions of that policy and write the result totransactionViolations_<id>. That fixes the employee's session client-side and also closes the acting-client blind spot atReportUtils.ts:2337.Reuse the existing
affectedTagNamesfast path inpushTransactionViolationsOnyxDataso only transactions holding a changed tag are touched — this recompute over large tag lists is what caused the freeze fixed in Fix Workspace Tags toggle freeze on large tag lists (scope violation recompute + pending switch feedback), so it must stay scoped.One caveat for the reviewer: the alternative fix is for the backend to include
transactionViolations_<id>in theDeletePolicyTagsresponse or push it over Pusher to each affected submitter. If you decide that is the right layer, this needs an internal engineer — that code is outside the App repo and I can't propose changes to it.What alternative solutions did you explore? (Optional)
Alternatives and supporting evidence
If the recording shows a Search page rather than the expense report, the cause is different. Inside
SearchScopeProvider,useOnyxsilently redirectstransactionViolations_*reads tosnapshot_<hash>(src/hooks/useOnyx.ts:74-99), so a live violation write does not re-render until the search re-runs. Several rows already work around this withuseOnyxWithoutSnapshots; the same treatment would be the fix.ReportScreenandSearchMoneyRequestReportPageare not wrapped, so the expense-report surface is unaffected.Ruled out: the optimistic math itself. Traced against this exact precondition (single tag list, 3 tags, delete Tag 1,
requiresTagon) every gate passes andtagOutOfPolicyis pushed. Nothing skips tags withpendingAction: DELETE, and an existing test covers the equivalentenabled: falseshape attests/unit/ReportUtilsTest.ts:13057-13108.tests/actions/PolicyTagTest.tshas zero assertions onTRANSACTION_VIOLATIONS, so the end-to-end delete → violation path is only covered indirectly — worth adding a test alongside the fix.Ruled out: stale UI or stale memoization on the report surfaces. The expense detail, transaction preview, and report transaction list all subscribe to
TRANSACTION_VIOLATIONSreactively.Minor inconsistency spotted nearby (not this bug, 3 tags exist here):
deleteWorkspaceCategoriesflipsrequiresCategory: falseoptimistically when the last category is removed (src/libs/actions/Policy/Category.ts:1402-1409);deletePolicyTagsnever flipsrequiresTag.What I could not verify: which account's screen the recording shows. That decides whether the primary gap is the admin's un-hydrated transaction or the employee's missing push — the fix above covers both. I also could not run a live repro: the test session handed to me failed during onboarding, so this analysis is code-evidence only.
Confidence: medium — mechanism is confirmed in code with no production log or runtime confirmation.
Related issues
No duplicates found. A quick scan of open
Expensify/Appissues for this symptom turned up nothing covering tag-violation staleness.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/101584)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run · no recording available
melvin-bot commentedon Sep 18, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @linhvovan29546 (
External)melvin-bot commentedon Sep 18, 2026
Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.
iprasen commentedon Sep 19, 2026
Proposal
What is the root cause of that problem?
deletePolicyTagsorsetWorkspaceTagEnabledinsrc/libs/actions/Policy/Tag.ts), onlyONYXKEYS.COLLECTION.POLICY_TAGSis updated optimistically and on the wire.deletePolicyTagsnorsetWorkspaceTagEnabledre-evaluates or emits optimistic data forONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONSfor existing transactions in Onyx holding that tag.MoneyRequestView) consumetransactionViolationsstored in Onyx. When server updates topolicyTagsarrive, no reactive listener triggersViolationsUtils.getViolationsOnyxDatafor existing transactions.What changes do you think we should make in order to solve the problem?
src/libs/actions/Policy/Tag.ts:deletePolicyTagsandsetWorkspaceTagEnabled, when tags are removed or disabled, look up loaded transactions for the givenpolicyIDand generate optimisticONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONSupdates usingViolationsUtils.getViolationsOnyxData.MoneyRequestView.tsx/useViolations):policyTagslist so that even when tag changes arrive via server push, existing transactions immediately reflect thetagOutOfPolicystate.What alternative solutions did you explore? (Optional)
Relying exclusively on the backend to push updated
transactionViolationsfor all transactions was considered, but backend push does not retroactively broadcast violations for all historical transactions upon tag deletion. Handling optimistic and reactive re-evaluation in App ensures immediate, consistent feedback across all platforms.linhvovan29546 commentedon Sep 20, 2026
@MelvinBot The backend already includes
transactionViolations_<id>in theDeletePolicyTagspushes it over Pusher to each affected submitter.MelvinBot commentedon Sep 20, 2026
Taking that as correct — I can't read the backend repo, so I'm going on your word. It drops my "subscribe to
policyTags_and recompute violations" recommendation as the primary fix.If the submitter already receives a
transactionViolations_<id>push, two App-side gaps remain that each produce "only after refresh". Which one applies depends on whose screen the recording shows.1. The viewer isn't a submitter (the admin who deleted the tag, or an approver watching the expense). No push targets them. Their only source of the violation is the optimistic recompute in the delete action, and that is scoped to transactions already hydrated in Onyx:
usePolicyDatabuildsreports/transactionsAndViolationsfrom the local derived value (src/hooks/usePolicyData/index.ts:24), andpushTransactionViolationsOnyxDatareturns early when the policy has no locally-held transactions (src/libs/ReportUtils.ts:2328-2331). An admin deleting the tag from the Tags page without the employee's expense loaded writes nothing, so the error only shows after a refetch.2. The viewer is in search scope (Reports/Expenses tab, or any surface under
SearchScopeProvider). The push does land intransactionViolations_<id>, but in search scopeuseOnyxredirects that read tosnapshot_<hash>(src/hooks/useOnyx.ts:76-100;transactionViolations_is in the redirect list atsrc/CONST/runtimeDefaults.ts:91). Only locally-initiated actions mirror their updates into the snapshot (src/libs/actions/IOU/SearchUpdate.ts:152); a Pusher update from another user's action never does, so the snapshot stays stale until the search re-runs — which a refresh does. Fix shape here is reading violations viauseOnyxWithoutSnapshotson the affected rows.If the recording is instead the submitter on the Inbox expense view, neither applies — that surface isn't in search scope and
MoneyRequestViewsubscribes to violations live — so the push isn't landing, and that's a delivery question outside the App repo.Which session does the video show — the admin deleting the tag or the employee — and on which screen (Inbox report vs Reports/Expenses)? That picks between 1 and 2 and I'll revise the proposal to match. I couldn't confirm it myself: the frames aren't extractable in my environment, and a two-account repro was out of reach for this run.
Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/101584)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
linhvovan29546 commentedon Sep 20, 2026
@MelvinBot The problem is that useTransactionViolations doesn’t re-render when it receives a Pusher event with policyTags_.
MelvinBot commentedon Sep 20, 2026
Agreed — that localizes it, and the hook confirms it. Revised proposal below.
Proposal
What is the root cause of that problem?
useTransactionViolationshas nopolicyTags_dependency. It subscribes to the transaction,transactionViolations_,report_,policy_and personal details (src/hooks/useTransactionViolations.ts:20-24) and memoizes on exactly those (src/hooks/useTransactionViolations.ts:44). A tag-list change arriving on its own therefore cannot add or cleartagOutOfPolicyon an open expense — the view keeps rendering the stored violations array until a refetch replaces it, which is what the refresh does.What changes do you think we should make in order to solve the problem?
Subscribe to
policyTags_<policyID>in the hook and re-derive the tag violations inside the same memo, right next to the existingsyncCustomUnitRateOutOfDateRangeViolationcall. That helper (src/libs/Violations/ViolationsUtils.ts:397) exists for this exact reason — a stored violation that can go stale against current policy data is re-derived at read time instead of written — so a siblingsyncTagOutOfPolicyViolation(violations, transaction, policyTagList, policy)fits the file's existing shape. The rules to reuse are already there: single-level atsrc/libs/Violations/ViolationsUtils.ts:89, multi-level atsrc/libs/Violations/ViolationsUtils.ts:241. It should covermissingTagas well astagOutOfPolicy, since deleting the last tag flips the list's "has enabled tags" state.Why this shape:
Search surfaces, and tests
This also covers the Reports/Expenses surfaces:
policyTags_is not in the snapshot redirect list (src/CONST/runtimeDefaults.ts:91haspolicy_, which does not matchpolicyTags_), so inside search scope the hook reads live tags even thoughtransactionViolations_still resolves fromsnapshot_<hash>.tests/actions/PolicyTagTest.tshas no assertions onTRANSACTION_VIOLATIONStoday. Worth adding unit coverage for the new helper: tag deleted →tagOutOfPolicyappears; tag re-added or re-enabled → clears;missingTagbehaviour when the last enabled tag goes away.Confidence: medium — code evidence only. I couldn't run a live repro this round; it needs two signed-in accounts.
Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/101584)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
linhvovan29546 commentedon Sep 20, 2026
@MelvinBot implement this
linhvovan29546 commentedon Sep 20, 2026
MelvinBot's proposal LGTM!
🎀 👀 🎀 C+ reviewed
29 remaining items
melvin-bot commentedon Oct 29, 2026
This issue has not been updated in over 14 days. @AndrewGable, @linhvovan29546 eroding to Weekly issue.
jeffrey-graham commentedon Nov 7, 2026
melvin-bot commentedon Nov 7, 2026
linhvovan29546 commentedon Nov 10, 2026
Not overdue. I'll ask the BC team to take a look.
dukenv0307 commentedon Nov 13, 2026
@MelvinBot create an E/E issue
MelvinBot commentedon Nov 13, 2026
Created an internal E/E issue for the backend investigation, including reproduction steps, acceptance criteria, and a link back to this issue.
view run