Skip to content

fix(walletkit): fail closed when a dApp sheet or Connected Apps can't render - #1063

Draft
CassioMG wants to merge 4 commits into
cg-dapp-request-replay-breakerfrom
cg-dapp-sheet-error-boundaries
Draft

CassioMG wants to merge 4 commits into
cg-dapp-request-replay-breakerfrom
cg-dapp-sheet-error-boundaries

Conversation

@CassioMG

@CassioMG CassioMG commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

Stacked on #1062. A render error inside a dApp sheet now fails closed instead of crashing the app:

  • Request sheet (with its header): the request is rejected, the sheet closes, and a toast says "Couldn't show this request". The dApp can send it again.
  • Connection sheet: the connection request is rejected the same way.
  • Connected Apps: if the list (or one dApp's row) can't render, the sheet says so and still offers "Disconnect all", so a broken entry can't hide the way out. Home itself stays up.

Bottom sheets render through a portal host near the app root, so a boundary has to sit inside each sheet's content to see its errors. This reuses the existing error boundary.

Lines changed

Kind Files Lines added Lines removed
Source code 3 312 44
Tests 2 389 10
Other (translations) 2 10 2
Total 7 711 56

Only this PR's own changes, not those of the PRs it's stacked on.

Screenshots and recordings (iPhone SE simulator on iOS 27.2, Pixel 10 Pro emulator)

For these, a local change that is not part of this PR makes the request sheet throw for one message, and Connected Apps throw for the test dApp's row. The request comes from a local WalletConnect test client.

Step iOS Android
A request whose sheet fails to render: the sheet closes, a toast shows, and the dApp receives "Varial couldn't show this request"
Connected Apps when the dApp's row fails to render: the message and "Disconnect all"
After "Disconnect all": the sessions are gone and the list renders again
Recording (screenshot time-lapse)

In dev builds React Native's LogBox also opens its full-screen "Render Error" panel for an error a boundary catches; it was turned off for these captures. Release builds have no LogBox.

Implementation details (for agents)

What changed:

  • src/providers/WalletKitProvider.tsx:

    • The request sheet's content is wrapped in an ErrorBoundary (resetKey = the request id). Its onError, handleRequestSheetRenderError, rejects the request with walletKit.sheetRenderFailedReason, marks it answered so the teardown neither rejects it again nor records a user rejection, shows the toast, clears the event at once (as the request handler's catch does) and runs the teardown. It does nothing while an approval is in flight or after a response, since that teardown is already on its way.
    • The connection sheet's content gets the same, rejecting the proposal (resetKey = the proposal id). It does nothing while the connection is being approved.
    • Such an error is caught while the sheet is still mounting, and @gorhom/bottom-sheet ignores dismiss() until the opening animation starts, so the sheet would go on to open, empty (found on the simulator). Each boundary therefore marks its sheet as failed, and the sheet's onChange dismisses it as soon as it reports an open index (closeSheetIfFailed).

    /**
    * Closes a sheet whose content failed to render, once the sheet reports it
    * has opened. A render error in a sheet's content is caught while the sheet
    * is still mounting, and the bottom sheet ignores dismiss() until its
    * opening animation starts, so the sheet would otherwise finish opening,
    * empty. A sheet that closes clears the flag.
    */
    const closeSheetIfFailed = (
    failedRef: { current: boolean },
    sheetRef: React.RefObject<BottomSheetModal | null>,
    index: number,
    ) => {
    if (index === -1) {
    // eslint-disable-next-line no-param-reassign
    failedRef.current = false;
    } else if (failedRef.current) {
    // eslint-disable-next-line no-param-reassign
    failedRef.current = false;
    sheetRef.current?.dismiss();
    }
    };
    /**
    * The connection sheet failed to render: rejects the proposal, says so, and
    * clears the connection, which closes the sheet. Skipped while the
    * connection is being approved, whose own teardown follows.
    */
    const handleConnectionSheetRenderError = () => {
    if (!proposalEvent || isConnecting) {
    return;
    }
    connectionSheetFailedRef.current = true;
    rejectSessionProposal({
    sessionProposal: proposalEvent,
    message: t("walletKit.sheetRenderFailedReason"),
    }).catch((error: unknown) => {
    logger.warn(
    "WalletKitProvider",
    "Failed to reject a proposal whose sheet failed to render",
    error,
    );
    });
    showToast({
    title: t("walletKit.sheetRenderFailedTitle"),
    message: t("walletKit.sheetRenderFailed"),
    variant: "error",
    });
    handleClearDappConnection();
    };
    /**
    * The request sheet failed to render: rejects the request, says so, and runs
    * the usual teardown, which closes the sheet. The request counts as
    * answered, so the teardown neither rejects it again nor records a user
    * rejection. Skipped once the request is being approved or was answered,
    * since that teardown is already on its way.
    */
    const handleRequestSheetRenderError = () => {
    if (
    !requestEvent ||
    approvalInFlightRef.current ||
    hasRespondedRef.current
    ) {
    return;
    }
    hasRespondedRef.current = true;
    requestSheetFailedRef.current = true;
    rejectSessionRequest({
    sessionRequest: requestEvent,
    message: t("walletKit.sheetRenderFailedReason"),
    }).catch((error: unknown) => {
    logger.warn(
    "WalletKitProvider",
    "Failed to reject a request whose sheet failed to render",
    error,
    );
    });
    showToast({
    title: t("walletKit.sheetRenderFailedTitle"),
    message: t("walletKit.sheetRenderFailed"),
    variant: "error",
    });
    // Clear the event now, as handleSessionRequest's catch does: the teardown
    // clears it only after a delay, and a dependency change before then would
    // hand this request to the dispatch effect again.
    clearEvent();
    handleClearDappRequest();
    };
    /**
    * Shows a request's first sheet (the request sheet, or the Blockaid sheet
    * in front of it) once the request is saved as on screen. If showing it

    modalRef={dappConnectionBottomSheetModalRef}
    analyticsEvent={AnalyticsEvent.VIEW_GRANT_DAPP_ACCESS}
    bottomSheetModalProps={{
    onDismiss: handleClearDappConnection,
    onChange: (index: number) =>
    closeSheetIfFailed(
    connectionSheetFailedRef,
    dappConnectionBottomSheetModalRef,
    index,
    ),
    }}
    customContent={
    // Inside the sheet: sheets render through the portal host, so a
    // boundary around the BottomSheet would never see these errors.
    <ErrorBoundary
    name="DappConnectionSheet"
    fallback={null}
    resetKey={proposalEvent?.id}
    onError={handleConnectionSheetRenderError}
    >
    <DappConnectionBottomSheetContent
    account={account}
    proposalEvent={proposalEvent}
    isConnecting={isConnecting}
    onConnection={handleDappConnection}
    onCancel={handleClearDappConnection}
    isMalicious={siteSecurityAssessment.isMalicious}
    isSuspicious={siteSecurityAssessment.isSuspicious}
    isUnableToScan={siteSecurityAssessment.isUnableToScan}
    securityWarningAction={() =>
    presentSecurityWarningDetail(SecurityContext.SITE)
    }
    onVerifyDomainPress={handleOpenVerifyDomain}
    />
    </ErrorBoundary>

    modalRef={dappRequestBottomSheetModalRef}
    analyticsEvent={AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION}
    enableContentPanningGesture={!isNonTransactionRequest}
    bottomSheetModalProps={{
    onDismiss: handleClearDappRequest,
    onChange: (index: number) =>
    closeSheetIfFailed(
    requestSheetFailedRef,
    dappRequestBottomSheetModalRef,
    index,
    ),
    enableDynamicSizing: true,
    }}
    customContent={
    <ErrorBoundary
    name="DappRequestSheet"
    fallback={null}
    resetKey={requestEvent?.id}
    onError={handleRequestSheetRenderError}
    >
    <DappRequestBottomSheetContent
    account={account}
    requestEvent={requestEvent}
    networkDetails={networkDetails}
    isSigning={isSigning}
    isValidatingMemo={isValidatingMemo}
    onBannerPress={onOpenAddMemoExplanationBottomSheet}
    onConfirm={handleDappRequest}
    onCancelRequest={handleClearDappRequest}
    isMalicious={requestIsMalicious}
    isSuspicious={requestIsSuspicious}
    isUnableToScan={requestIsUnableToScan}
    transactionScanResult={
    isNonTransactionRequest ? undefined : transactionScanResult
    }
    securityWarningAction={() =>
    presentSecurityWarningDetail(SecurityContext.TRANSACTION)
    }
    signTransactionDetails={signTransactionDetails}
    isMemoMissing={isMemoMissing}
    isUndisplayable={isRequestUndisplayable}
    onDetailsRenderError={handleDetailsRenderError}
    />
    </ErrorBoundary>

  • src/components/screens/HomeScreen/ConnectedApps.tsx:

    • The sheet's content is wrapped in a boundary whose fallback is a message row. The footer's "Disconnect all" stays, since a broken entry means there's a session.
    • The default export now wraps the component itself in a boundary, because it builds the rows outside the sheet. Its fallback is ConnectedAppsUnavailable. HomeScreen is unchanged.

    scrollViewFooterComponent={hasSessions ? renderFooter : undefined}
    customContent={
    // Inside the sheet: sheets render through the portal host, so the
    // boundary around ConnectedApps never sees these errors. The footer's
    // "Disconnect all" stays, since a broken entry means there's a session.
    <ErrorBoundary
    name="ConnectedAppsSheet"
    fallback={<ConnectedAppsLoadFailed />}
    resetKey={activeSessionsKey}
    >
    <ConnectedAppsBottomSheet
    connectedDapps={connectedDapps}
    discoverEnabled={discoverEnabled}
    onDisconnect={handleDisconnectSession}
    onGoToDiscover={handleGoToDiscover}
    />
    </ErrorBoundary>
    }
    />
    );
    };
    /**
    * Connected Apps as Home mounts it. If it fails to render, Home stays up and
    * a stand-in sheet still offers "Disconnect all", so a dApp that breaks the
    * list can't hide the way out. Disconnecting changes the sessions, which
    * clears the error.
    */
    const ConnectedAppsWithFallback: React.FC<ConnectedAppsProps> = (props) => {
    const { activeSessions } = useWalletKitStore();
    const { bottomSheetRef } = props;
    return (
    <ErrorBoundary
    name="ConnectedApps"
    fallback={<ConnectedAppsUnavailable bottomSheetRef={bottomSheetRef} />}
    resetKey={Object.keys(activeSessions).join(",")}
    >
    <ConnectedApps {...props} />
    </ErrorBoundary>
    );
    };
    export default ConnectedAppsWithFallback;

  • src/components/screens/HomeScreen/ConnectedAppsUnavailable.tsx (new): a stand-in Connected Apps sheet with the message and a "Disconnect all" button.

  • i18n (en, pt): walletKit.sheetRenderFailedTitle, walletKit.sheetRenderFailed, walletKit.sheetRenderFailedReason, connectedApps.loadFailed. The button reuses the existing "Disconnect all".

Verification:

  • yarn test: 355 suites pass, 5,316 tests pass and 6 are skipped. New tests: a request sheet and a connection sheet that fail to render (one rejection with the reason, the toast, no user rejection recorded, the next request renders); a sheet that failed while opening is dismissed once it opens, and the next one isn't; a sessions refresh or a new request during the teardown (the failed request isn't handled again, the new one shows); Connected Apps' two fallbacks and their "Disconnect all".
  • yarn lint:ts passes, and ESLint and Prettier pass on every changed file.
  • Dev builds on an iPhone SE (3rd generation) iOS 27.2 simulator and a Pixel 10 Pro emulator, with a local change (not in this PR) making the request sheet throw for one message and Connected Apps throw for the test dApp's row (screenshots above): the request sheet closes, the toast shows and the dApp receives "Varial couldn't show this request"; Connected Apps shows the message and "Disconnect all", which disconnects and brings the list back. The first device run found that a sheet failing while it opens stayed up empty, which the onChange dismissal fixes.
  • A fresh review found the same empty-sheet issue and that the request's event wasn't cleared at once; both are fixed here with tests.

🤖 Generated with Claude Code

CassioMG and others added 4 commits November 11, 2026 21:51
… to render

Bottom sheets render through the portal host near the app root, so a
render error inside a dApp sheet went straight to the fatal error
handler. An error boundary inside the request sheet and one inside the
connection sheet now reject the request or proposal with its own reason,
close the sheet and show a toast. The rejected request counts as
answered, so the teardown neither rejects it again nor records a user
rejection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t be shown

Connected Apps builds its rows on Home and draws them in a bottom sheet,
so a render error in either took Home or the sheet down, along with the
way to disconnect the dApp behind it. An error boundary inside the sheet
now shows a message and keeps the sheet's "Disconnect all" footer, and
one around the component swaps in a stand-in sheet with the message and
its own "Disconnect all" button. Disconnecting changes the sessions,
which clears the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A render error in a sheet's content is caught while the sheet is still
mounting, and the bottom sheet ignores dismiss() until its opening
animation starts, so the request or connection sheet went on to open,
empty, over its backdrop. The boundary now marks the sheet as failed,
and the sheet is dismissed as soon as it reports that it has opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The request sheet's boundary left the request in the event store until
the deferred teardown, so a sessions refresh in between handed it to the
dispatch effect again (a second rejection and toast), and a request
arriving in between was replaced and never shown. Clear the event at
once, as handleSessionRequest's catch already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-b136dec5fad99c0402ba
Backend: sandbox (CassioMG). SDF collaborators only — install instructions in the release description.

@github-actions

Copy link
Copy Markdown
Contributor

iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-e985966f326965af9588
Backend: sandbox (CassioMG). SDF collaborators only — install instructions in the release description.

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.

1 participant