Skip to content

[Due for payment 2026-06-16] [$250] [Sentry: APP-DWR] Web – Crash in Copy Policy Settings when a tag group has no tags: Cannot convert undefined or null to object #91965

Closed

Description

@mountiny

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.3.83-1
Reproducible in staging?: Yes
Reproducible in production?: Not yet confirmed (introduced in staging, Copy Policy Settings is gated)
Issue reported by: Internal Sentry investigation
Sentry: https://expensify.sentry.io/issues/APP-DWR

Action Performed

  1. Have a workspace where at least one tag group has no tags defined (tags is null or missing)
  2. Navigate to Copy Policy Settings: /policy/<policyID>/copy-settings
  3. Wait for the feature selection page to render

Expected Result

Copy Policy Settings page renders the feature list normally.

Actual Result

Full-page "Uh-oh, something went wrong!" ErrorBoundary crash. The app is completely unusable until the page is refreshed.

Workaround

Unknown.

Platforms

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

Technical details

Source: src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx:65

The reduce callback calls Object.values(tagGroup.tags) without guarding against a null or undefined tags field, which can exist on tag groups that have no tags yet.

// Crashes when tagGroup.tags is null/undefined:
Object.values(policyTags).reduce((sum, tagGroup) =>
    sum + Object.values(tagGroup.tags).filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length, 0)

// Fix: guard tagGroup.tags before calling Object.values()
Object.values(tagGroup.tags ?? {}).filter(...)

Sentry stack: CopyPolicySettingsSelectFeaturesPage render → Object.values(tagGroup.tags) → TypeError → ErrorBoundary.

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

Activity

  1. added
    ExternalAdded to denote the issue can be worked on by a contributor
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 28, 2026
  2. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 28, 2026
  3. melvin-bot commented on May 28, 2026

    @melvin-bot

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

  4. melvin-bot commented on May 28, 2026

    @melvin-bot
  5. yusufdeveloper2903 commented on May 28, 2026

    @yusufdeveloper2903
    Contributor

    Proposal

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

    Opening Copy Policy Settings (/policy/<policyID>/copy-settings) crashes the whole page with the "Uh-oh, something went wrong!" ErrorBoundary when the source workspace has a tag group whose tags field is null/missing (a tag list created with no tags yet). The page is unusable until refresh.

    What is the root cause of that problem?

    CopyPolicySettingsSelectFeaturesPage computes totalTags during render and calls Object.values() on each tag group's tags without guarding the argument:

    const totalTags = policyTags
    ? Object.values(policyTags).reduce((sum, tagGroup) => sum + Object.values(tagGroup.tags).filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length, 0)
    : 0;

    Object.values(tagGroup.tags) throws TypeError: Cannot convert undefined or null to object the moment tagGroup.tags is null/undefined. Because this runs synchronously in the render body, the throw bubbles straight to the ErrorBoundary → full-page crash.

    The tell is that every other count in this same block already guards its argument with ?? {} — categories (L63), taxes (L67), report fields (L68), distance rates (L70), per-diem (L71). Only the nested tagGroup.tags lookup on L65 was missed, so empty tag groups slip through.

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

    Guard the argument inside Object.values(), matching the convention used on every adjacent line of the same function:

    const totalTags = policyTags
        ? Object.values(policyTags).reduce((sum, tagGroup) => sum + Object.values(tagGroup.tags ?? {}).filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length, 0)
        : 0;

    This is the canonical app-wide pattern for reading tag lists — e.g. PolicyUtils.ts:914, WorkspaceTagsPage.tsx:238, SearchQueryUtils.ts:1176 all use Object.values(<list>.tags ?? {}). Scope is the single L65 expression; no data-layer or type changes.

    Also fix the same latent crash in the analog Duplicate-Workspace flow that Copy Policy Settings was modeled on:

    return Object.values(policyTags).reduce(
    (sum, tagGroup) => sum + (Object.values(tagGroup.tags)?.filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length ?? 0),
    0,
    );

    That code looks guarded but is not — Object.values(tagGroup.tags)?.filter(...).length ?? 0 places the optional chain after Object.values() has already thrown, so a null tag group crashes the Duplicate Workspace feature page identically. Apply the same ?? {} fix there.

    Verification: create a workspace with one empty tag group (no tags), open /policy/<policyID>/copy-settings → before, ErrorBoundary crash; after, page renders and the empty group contributes 0 to the tag count. Same check on the Duplicate Workspace feature page.

    What alternative solutions did you explore? (Optional)

    1. Copy the existing Duplicate-Workspace pattern Object.values(tagGroup.tags)?.filter(...).length ?? 0 — rejected: the optional chain runs after Object.values() already threw, so it never prevents the crash. It is the latent bug, not a fix.
    2. Wrap totalTags in a try/catch or guarded useMemo — rejected: masks the null shape instead of handling it, and is inconsistent with the surrounding lines that all simply use ?? {}.
    3. Normalize tags to {} at the Onyx/data layer — rejected: large blast radius across every tag consumer; empty tag groups are legitimate data, and the app convention is to guard at the read site.
  6. github-actions commented on May 28, 2026

    @github-actions
    Contributor

    ⚠️ @yusufdeveloper2903 Thanks for your proposal. Please update it to follow the proposal template, as proposals are only reviewed if they follow that format (note the mandatory sections).

  7. aswin-s commented on May 28, 2026

    @aswin-s
    Contributor

    Proposal

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

    On Web, opening Copy Policy Settings (/policy/<policyID>/copy-settings) for a workspace where a tag group has no tags (tags is null/undefined) triggers a full-page ErrorBoundary crash ("Uh-oh, something went wrong!"), making the app unusable until refresh. Expected: the feature selection page renders the feature list normally, counting such a tag group as contributing zero tags.

    What is the root cause of that problem?

    In src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx:65, the totalTags reduce callback calls Object.values(tagGroup.tags) directly. When a tag group has no tags, tagGroup.tags is null/undefined, and Object.values(null) throws TypeError: Cannot convert undefined or null to object, which propagates up through render to the ErrorBoundary. Every sibling count in the same block (lines 63, 67, 68, 70) already guards with Object.values(... ?? {}); this one site was missed.

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

    1. In CopyPolicySettingsSelectFeaturesPage.tsx:65, change Object.values(tagGroup.tags) to Object.values(tagGroup.tags ?? {}), mirroring the existing guard pattern used by the surrounding counts on lines 63, 67, 68, and 70.

    What alternative solutions did you explore? (Optional)

  8. trasnake87 commented on May 28, 2026

    @trasnake87
    Contributor

    Proposal

    Navigating to /policy/<policyID>/copy-settings on a workspace that has at least one tag group with no tags (i.e. tags is null/undefined on the Onyx record) crashes the page with TypeError: Cannot convert undefined or null to object, throwing the user into the full-page ErrorBoundary.

    Root cause

    CopyPolicySettingsSelectFeaturesPage.tsx computes totalTags by reducing every tag group and calling Object.values(tagGroup.tags) directly. Object.values() throws synchronously when its argument is null/undefined, so a single tag group with an empty tags field is enough to take the whole render down.

    Source at src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx:64-66:

    const totalTags = policyTags
        ? Object.values(policyTags).reduce((sum, tagGroup) => sum + Object.values(tagGroup.tags).filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length, 0)
        : 0;

    While the PolicyTagList Onyx type declares tags: PolicyTags as non-optional (src/types/onyx/PolicyTag.ts:41-59), the runtime data does not honor that — every other consumer in the codebase guards the field. For example, src/libs/PolicyUtils.ts:811-813:

    function hasTags(policyTagList: OnyxEntry<PolicyTagLists>): boolean {
        const tagLists = getTagLists(policyTagList);
        return tagLists.some((tagList) => Object.keys(tagList.tags ?? {}).length > 0);
    }

    and src/libs/PolicyUtils.ts:928-936:

    function getTagNamesFromTagsLists(policyTagLists: PolicyTagLists): string[] {
        const uniqueTagNames = new Set<string>();
        for (const policyTagList of Object.values(policyTagLists ?? {})) {
            for (const tag of Object.values(policyTagList.tags ?? {})) {
                uniqueTagNames.add(tag.name);
            }
        }
        return Array.from(uniqueTagNames);
    }

    The same ?? {} guard is used in at least src/libs/SearchQueryUtils.ts:1176, src/hooks/useAdvancedSearchFilters.ts:287, src/pages/settings/Rules/Fields/AddTagPage.tsx:34, and src/pages/workspace/tags/WorkspaceTagsPage.tsx:238,249,256,282. The CopyPolicySettings page is the outlier that omitted it.

    1 - CopyPolicySettingsSelectFeaturesPage crashes on Object.values(tagGroup.tags) — the bug reported in the issue.

    2 - The sister page WorkspaceDuplicateSelectFeaturesForm has the same crash with a flawed "half-fix" — at src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx:66-74:

    const totalTags = useMemo(() => {
        if (!policyTags) {
            return 0;
        }
        return Object.values(policyTags).reduce(
            (sum, tagGroup) => sum + (Object.values(tagGroup.tags)?.filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length ?? 0),
            0,
        );
    }, [policyTags]);

    The ?.filter optional-chain looks defensive but is unreachable: Object.values() throws synchronously on null/undefined before it can return anything to chain against. Any workspace that crashes Copy Policy Settings will also crash Workspace Duplicate via /policy/<policyID>/duplicate. Both should be fixed together since they share the exact bug pattern and the user-facing surface is identical (Sentry will start reporting WorkspaceDuplicateSelectFeaturesForm next).

    Proposed fix

    Add the ?? {} guard directly inside the Object.values() call so the reducer treats a tag group with no tags field as contributing zero.

    // src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx
    const totalTags = policyTags
        ? Object.values(policyTags).reduce(
              (sum, tagGroup) => sum + Object.values(tagGroup.tags ?? {}).filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length,
              0,
          )
        : 0;

    Apply the same guard to the duplicate page and drop the dead ?. / ?? 0 since .length already returns a number:

    // src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx
    const totalTags = useMemo(() => {
        if (!policyTags) {
            return 0;
        }
        return Object.values(policyTags).reduce(
            (sum, tagGroup) => sum + Object.values(tagGroup.tags ?? {}).filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length,
            0,
        );
    }, [policyTags]);

    Why this approach

    The Object.values(x.tags ?? {}) pattern is already the established convention in this codebase (8+ call sites, including the canonical hasTags and getTagNamesFromTagsLists helpers in PolicyUtils.ts), so this change brings the two outliers into line with the rest of the policy-tags consumers. It is a minimal, in-place fix that does not change the PolicyTagList type definition — flipping tags to optional there would ripple through many files and is not justified by a single crash. A wrapper helper (getTagsCount(policyTags)) was considered but rejected because the two existing call sites differ slightly (one is memoized, one is not, and only the duplicate page early-returns on !policyTags) and abstracting them now would be premature for a two-line guard. The fix is also consistent with the issue author's suggested patch.

  9. MelvinBot commented on May 28, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    In CopyPolicySettingsSelectFeaturesPage.tsx:65, Object.values(tagGroup.tags) is called inside a reduce without guarding against tagGroup.tags being null or undefined. The outer policyTags is null-checked, but individual tag groups can have missing tags fields at runtime (e.g., a workspace with an empty tag group). Object.values(null) throws a TypeError, which propagates to the ErrorBoundary and crashes the page.

    The TypeScript type (PolicyTagList.tags: PolicyTags) is declared as required, so the compiler doesn't flag it — but Onyx data from the server can have missing fields at runtime, which is why 10+ other call sites in the codebase already guard with ?? {}.

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

    Add a ?? {} fallback on tagGroup.tags so Object.values receives an empty object instead of throwing:

    This matches the defensive pattern used consistently throughout the codebase (e.g., PolicyUtils.ts, TagPicker, SearchQueryUtils, TransactionUtils).

    What alternative solutions did you explore? (Optional)

    Could make tags optional in the PolicyTagList type (tags?: PolicyTags), but that would require updating every access site and doesn't match the existing codebase convention of guarding with ?? {} at call sites.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement this to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  10. ChavdaSachin commented on May 28, 2026

    @ChavdaSachin
    Contributor

    @MelvinBot, please implement this

  11. MelvinBot commented on May 28, 2026

    @MelvinBot
    Contributor

    🤖 Draft PR created: #91968
    Please mark it as "Ready for review" when it is ready for review.

  12. 21 remaining items

  13. added a commit that references this issue on Jun 9, 2026
  14. added
    Awaiting PaymentAuto-added when associated PR is deployed to production
    and removed on Jun 15, 2026
  15. melvin-bot commented on Jun 15, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  16. melvin-bot commented on Jun 15, 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/2059992332026743806/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 Jun 17, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor+: @ChavdaSachin due $250 via NewDot

    This doesn't look like it's paid via project so I'm paying. Comment/reopen if otherwise.

  18. quinthar commented on Jun 29, 2026

    @quinthar
    Contributor

    Approved $250 to @ChavdaSachin

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