Skip to content

[Due for payment 2026-06-01] [$250] [Sentry: APP-6K1] FullstoryInitHandler unhandled rejection ("Shutdown called") crashes web app #91290

Closed

Description

@mountiny

Sentry

https://expensify.sentry.io/issues/APP-6K1

Impact (snapshot at filing)

  • Users (total since first seen): 9,396
  • Events: 17,283
  • Users (last 14d): ~249
  • First seen: 2026-02-27
  • Last seen: now (ongoing)
  • Platform: web (standalone), multiple browsers/OSes
  • App version(s): seen through 9.3.73
  • Affected route(s) (parameterized): any route mounted while FullStory is initializing — captured frequently on /r/:reportID

Stack trace (top frames, first-party only)

src/FullstoryInitHandler.tsx:18   FS.getSessionURL().then(url => ...)
  -> src/libs/Fullstory/index.ts:102   FullStory('getSessionAsync', { format: 'url' })
  -> @fullstory/snippet              promise rejects: Error: Shutdown called
  -> React: commitHookEffectListMount -> top-level unhandled rejection

Suspected cause

The FullStory snippet enters a shutdown state in some sessions (ad-blocker, GDPR / Do-Not-Track opt-out, user-killed snippet, post-FS('shutdown') call). The .then(...) chain in FullstoryInitHandler has no .catch, so the promise rejection bubbles up as an unhandled error from inside the useEffect's passive mount commit phase, which React surfaces as a top-level crash.

One-line fix

Add a .catch to swallow (and optionally log) the rejection:

FS.getSessionURL()
    .then((url) => {
        if (!url) {
            return;
        }
        Sentry.setContext(CONST.TELEMETRY.CONTEXT_FULLSTORY, {url});
    })
    .catch((error) => Log.warn('FullstoryInitHandler: getSessionURL failed', {error}));

This is a recoverable, expected condition for any user whose FullStory session is shut down — we never need the URL in that case.

Reproduction

  • Force the FullStory snippet into a shutdown state (e.g. call FS('shutdown') from the devtools console, or load with an ad-blocker that kills edge.fullstory.com/s/fs.js)
  • Navigate so that FullstoryInitHandler re-mounts
  • App crashes with the error overlay (Uh-oh, something went wrong!)

Related

  • Sibling Sentry IDs (if any): none identified
  • Prior GH issues: none found for "Shutdown called"
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. self-assigned this
    on May 21, 2026
  2. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 21, 2026
  3. MelvinBot commented on May 21, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    FullstoryInitHandler.tsx:18 calls FS.getSessionURL().then(...) without a .catch(). When FullStory is in a shutdown state (ad-blocker, DNT opt-out, or explicit FS('shutdown') call), the underlying FullStory('getSessionAsync') rejects with "Shutdown called". The unhandled rejection bubbles up through React's passive effect commit phase and crashes the app.

    Notably, isInitialized() still returns true after shutdown, so getSessionURL proceeds to call into a dead FullStory instance instead of bailing out.

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

    Add a .catch() to the getSessionURL() promise chain in FullstoryInitHandler.tsx:18-24 to gracefully handle the rejection. The session URL is purely informational (sets Sentry context) — failure is always safe to swallow with a warning log.

    What alternative solutions did you explore? (Optional)

    An alternative would be tracking shutdown state inside src/libs/Fullstory/index.ts (e.g., a hasShutdown flag set alongside the FS('shutdown') call at line 66) and returning early from getSessionURL when set. However, the .catch() approach is simpler, covers any rejection reason (not just shutdown), and matches the fact that this call is best-effort.

    Supporting evidence
    • The useEffect in FullstoryInitHandler (lines 16-24) has no error handling on the promise chain.
    • FS.init() on web is a no-op, so calling it before getSessionURL provides no guard.
    • consentAndIdentify calls FS('shutdown') at line 66 when shouldInitialize returns false, but isInitialized() from @fullstory/browser remains true after shutdown.
    • Sentry shows 9,396 affected users / 17,283 events, confirming this is a frequent production crash.

    Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on May 21, 2026
  5. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 21, 2026
  6. KJ21-ENG commented on May 21, 2026

    @KJ21-ENG
    Contributor

    Proposal

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

    On web, the app can crash while FullstoryInitHandler is mounting if the FullStory browser snippet rejects getSessionAsync with Error: Shutdown called.

    What is the root cause of that problem?

    src/FullstoryInitHandler.tsx calls FS.getSessionURL().then(...) inside the useEffect that runs when user metadata changes, but it never handles a rejected promise.

    On web, FS.getSessionURL() is a thin wrapper around FullStory('getSessionAsync', {format: 'url'}). If the FullStory snippet is already initialized but in a shutdown state, for example after FullStory('shutdown'), an opt-out/ad-blocker path, or another snippet shutdown condition, that async FullStory call rejects instead of returning a URL. Because the effect chain has no .catch, the rejection becomes an unhandled promise rejection during React's passive effect mount, and the web app surfaces it as a top-level crash.

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

    Handle the recoverable FullStory lookup failure in FullstoryInitHandler.

    Concretely, import Log in src/FullstoryInitHandler.tsx and add a .catch after the existing FS.getSessionURL().then(...) chain:

    FS.getSessionURL()
        .then((url) => {
            if (!url) {
                return;
            }
    
            Sentry.setContext(CONST.TELEMETRY.CONTEXT_FULLSTORY, {url});
        })
        .catch((error) => Log.warn('[FullstoryInitHandler] Failed to get FullStory session URL', {error}));

    This keeps the useful Sentry context when FullStory returns a URL, but treats a missing/shutdown FullStory session as non-fatal. We do not need to block rendering or crash the app just because telemetry context cannot be attached.

    Scope boundaries: this should only change the FullStory session URL lookup used for Sentry context in FullstoryInitHandler. It should not change FullStory initialization, consent/identity behavior, Sentry initialization, or native FullStory behavior.

    What alternative solutions did you explore? (Optional)

    Catching inside src/libs/Fullstory/index.ts would also prevent this specific rejection, but it would hide failures from every caller of getSessionURL(). Handling it in FullstoryInitHandler is better scoped because this caller explicitly treats the URL as optional context. Checking isInitialized() is already done in the wrapper, so the missing piece is rejection handling for the initialized-but-shutdown state.

  7. melvin-bot commented on May 21, 2026

    @melvin-bot

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

  8. wildan-m commented on May 21, 2026

    @wildan-m
    Contributor

    Proposal

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

    On web, the app can crash with the "Uh-oh, something went wrong!" error overlay because of an unhandled promise rejection (Error: Shutdown called) originating from FullstoryInitHandler. This is a high-volume Sentry crash affecting thousands of users whenever the FullStory session is in a shutdown state (ad-blocker that kills fs.js, GDPR / Do-Not-Track opt-out, or a prior FS('shutdown') call).

    What is the root cause of that problem?

    FullstoryInitHandler calls FS.getSessionURL() inside a useEffect and attaches only a .then() handler — there is no .catch():

    useEffect(() => {
    FS.init(userMetadata);
    FS.getSessionURL().then((url) => {
    if (!url) {
    return;
    }
    Sentry.setContext(CONST.TELEMETRY.CONTEXT_FULLSTORY, {url});
    });
    }, [userMetadata]);

    getSessionURL() resolves by delegating to the FullStory browser SDK. It only short-circuits when isInitialized() is false; once the snippet has initialized it calls FullStory('getSessionAsync', {format: 'url'}):

    getSessionURL: async () => {
    if (!isInitialized()) {
    return;
    }
    return FullStory('getSessionAsync', {format: 'url'});
    },

    The problem is that isInitialized() still returns true for a session that has initialized and then been shut down. In that state getSessionAsync rejects with Error: Shutdown called. Because the .then() chain in FullstoryInitHandler has no rejection handler, the rejection escapes during React's passive-effect mount commit phase and is surfaced as a top-level unhandled rejection, which trips the global error boundary and crashes the web app.

    This is an expected, recoverable condition: when the FullStory session is shut down we simply have no session URL to attach to the Sentry context, and the app should continue normally.

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

    Attach a rejection handler to the FS.getSessionURL() promise chain in FullstoryInitHandler so a shutdown (or any other) rejection is caught instead of bubbling up as an unhandled rejection.

    Concretely, in src/FullstoryInitHandler.tsx add a .catch() to the existing FS.getSessionURL().then(...) chain at the line referenced above. The catch handler should swallow the rejection and log it at warn level (e.g. via Log.warn from src/libs/Log) so the failure is still observable in telemetry without crashing the app. When the session URL is unavailable we already skip setting the Sentry context (the existing if (!url) return; guard), so no Sentry context is set in the shutdown case — which is the correct behavior since there is no URL to record.

    This keeps the fix scoped to the single component that owns the unguarded promise, leaves the FullStory wrapper's existing isInitialized() short-circuit untouched, and resolves the crash for every shutdown scenario listed in the report (ad-blocker, opt-out, manual shutdown).

    What alternative solutions did you explore? (Optional)

    Adding the try/catch (or .catch()) inside getSessionURL() in src/libs/Fullstory/index.ts was considered, but that wrapper is intentionally a thin pass-through to the SDK and other callers may want to observe the rejection; catching at the FullstoryInitHandler call site keeps the error-handling decision local to the consumer that doesn't need the URL.

  9. changed the title [-][Sentry: APP-6K1] FullstoryInitHandler unhandled rejection ("Shutdown called") crashes web app[/-] [+][$250] [Sentry: APP-6K1] FullstoryInitHandler unhandled rejection ("Shutdown called") crashes web app[/+] on May 21, 2026
  10. melvin-bot commented on May 21, 2026

    @melvin-bot
  11. aswin-s commented on May 21, 2026

    @aswin-s
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-05-21 11:47:25 UTC.

    Proposal

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

    On web, when the FullStory snippet enters a shutdown state (ad-blocker, GDPR/Do-Not-Track opt-out, or a prior FS('shutdown') call), FullstoryInitHandler triggers an unhandled promise rejection (Error: Shutdown called) that React surfaces during the passive-effect commit phase as a top-level crash with the "Uh-oh, something went wrong!" overlay (Sentry APP-6K1: 9,396 users / 17,283 events). Shutdown is an expected, recoverable condition for these users — the app should silently skip setting the Sentry context and keep running.

    What is the root cause of that problem?

    In src/FullstoryInitHandler.tsx#L16-L24 the useEffect calls:

    FS.getSessionURL().then((url) => {
        if (!url) return;
        Sentry.setContext(CONST.TELEMETRY.CONTEXT_FULLSTORY, {url});
    });

    with no .catch. The web implementation at src/libs/Fullstory/index.ts#L98-L103 delegates to FullStory('getSessionAsync', {format: 'url'}), which rejects with Error: Shutdown called whenever the snippet has been shut down. The existing isInitialized() guard does not cover this state — once initialized, isInitialized() keeps returning true even after FullStory('shutdown') runs (see the consent path at index.ts#L64-L67, where shutdown is fired without flipping any local state). The rejection escapes the effect and becomes an unhandled rejection at top level.

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

    Attach a .catch to the FS.getSessionURL() chain at the single call site in src/FullstoryInitHandler.tsx, swallowing the rejection and logging it at warn level via Log (@libs/Log):

    FS.getSessionURL()
        .then((url) => {
            if (!url) {
                return;
            }
            Sentry.setContext(CONST.TELEMETRY.CONTEXT_FULLSTORY, {url});
        })
        .catch((error) => Log.warn('[FullstoryInitHandler] Failed to get FullStory session URL', {error}));

    The session URL is purely best-effort Sentry context; the existing if (!url) return; already treats a missing URL as fine. Catching at this call site keeps the error-handling decision local to the consumer that has opted into "URL is optional", preserves the rejection signal for any future caller that does care, and resolves the crash for every shutdown scenario in the report (ad-blocker, opt-out, manual shutdown).

    Scope: only the FullStory session-URL lookup used for Sentry context in FullstoryInitHandler. No changes to FullStory initialization, consent/identity, native FullStory, or Sentry init.

    What alternative solutions did you explore? (Optional)

    1. Catch inside getSessionURL/getSessionId in src/libs/Fullstory/index.ts — rejected. Grep confirms getSessionURL has exactly one consumer (FullstoryInitHandler) and getSessionId has zero, so wrapping the library adds no current coverage while silently hiding the rejection from any future caller that may legitimately want to react to it. The call-site catch is sufficient and keeps the library a thin pass-through.

    2. Track a local hasShutdown flag alongside FullStory(SHUTDOWN) at index.ts:66 and short-circuit getSessionURL — rejected. External shutdowns (ad-blocker killing fs.js, DNT, user devtools, snippet self-shutdown) bypass our code path entirely, so the .catch is still required; the flag would be dead defense.

    3. Extend isInitialized() guard — @fullstory/browser's isInitialized() does not expose a post-shutdown predicate, so we cannot synchronously detect this state. The catch is the correct guard.

  12. aswin-s commented on May 21, 2026

    @aswin-s
    Contributor

    Proposal updated by depth-review — Posted proposal's change #2 (defensive catch inside getSessionId/getSessionURL at the library boundary) is over-scope and hides failures from any future caller — competitors KJ21 and wildan-m both explicitly reject it, and grep confirms FullstoryInitHandler is the only consumer of getSessionURL with zero consumers for getSessionId, so the call-site catch alone fully resolves the bug.

  13. 24 remaining items

  14. melvin-bot commented on May 25, 2026

    @melvin-bot

    @mananjadhav The PR fixing this issue has been merged! The following checklist (instructions) will need to be completed before the issue can be closed. Please copy/paste the BugZero Checklist from here into a new comment on this GH and complete it. If you have the K2 extension, you can simply click: [this button]

  15. melvin-bot commented on May 29, 2026

    @melvin-bot

    @mananjadhav Whoops! This issue is 2 days overdue. Let's get this updated quick!

  16. melvin-bot commented on May 31, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  17. melvin-bot commented on May 31, 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/2057427299634251694/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
  18. mallenexpensify commented on Jun 1, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor+: @mallenexpensify due $250 via NewDot

    Doesn't look like we need a checklist here. @mananjadhav in the future can you please create one and just be N/A or 'not needed'? Otherwise I have to dig around a bit to find/learn more. Thx

  19. mananjadhav commented on Jun 6, 2026

    @mananjadhav
    Collaborator

    Okay noted. I generally post a comment it's not needed. But I was out few days so I couldn't post.

  20. garrettmknight commented on Jun 10, 2026

    @garrettmknight
    Contributor

    $250 approved for @mananjadhav

  21. moved this from Done to SUBISSUE in [#whatsnext] #qualityon Jul 2, 2026
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