Skip to content

Show a failed app import or redeploy once, and keep a failed import beside its form - #1871

Merged
dawsontoth merged 5 commits into
claude/1699-missing-package-json-guidancefrom
claude/1833-deploy-failure-inline
Oct 12, 2026
Merged

dawsontoth merged 5 commits into
claude/1699-missing-package-json-guidancefrom
claude/1833-deploy-failure-inline

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

⊙ Problem

Every failed deploy showed the same failure twice. useDeployComponentMutation didn't opt out of the global mutation toast, so mutationErrorHandler toasted it, and then the import form ("Failed to import …") or the redeploy modal ("Failed to redeploy …") toasted it again. The streamed redeploy also showed it in its progress panel, so that path showed it three times. The import form had no persistent failure display at all, just a toast that faded, even though these are failures the user has to fix and retry: #1699 measured one session retrying the same import 9 times in 22 minutes. Closes #1833.

❓ Your call: This fixes one mutation, not the global handler. #1808 tracks the general double report and toast for route loads, and changing errorHandler there is a separate decision. The PR is stacked on #1841 because it rewrites that PR's global-toast test, so it should merge after #1841 or be rebased onto stage once #1841 lands.

💡 Solution

The deploy mutation now opts out of the global toast. It keeps the RUM report with a mutation-level console.error, the pattern AGENTS.md documents in "Dropping errorHandler from an onError also drops the RUM report". Each caller now shows the failure once:

Every one of these displays now gets its text from describeError. Before, the import and redeploy toasts used error.message, which on the buffered path is axios's Request failed with status code 500 and not the server's reason. Only the global toast had shown the real text.

Both callers now await mutateAsync instead of passing callbacks to mutate. React Query drops a mutate caller's callbacks once the component unmounts. Before this PR the global toast still reached a user who had navigated away, so without this change a failure that arrived after they left would have been silent. In that case it now arrives as a toast. Because the continuation now outlives its form, the second commit keeps it out of whatever is on screen by then. An import that finishes after the form unmounted still refreshes the tree and reports its result, but it leaves the editor selection alone. The redeploy modal records which deploy its panel is showing, and any other deploy never settles that panel or closes the modal.

Also fixed: an inconclusive stream (SSEInconclusiveError) no longer shows a generic "Error" toast on top of the "verifying…" health-check result. That toast came from the global handler, which never treated the inconclusive case as anything special. RUM still receives it, as it did before.

❓ Your call: while the import form is mounted, the failure is shown inline only, with no toast. The progress toast disappears and the reason appears right above the button the user just clicked. If you want a toast as well, so the user notices the spinner ended, that is a one-line toast.error in the mounted branch. I left it out because the issue asks for one display.

#1808 is the broader version of this: React Query's global errorHandler double-reports and double-shows failed route loads. This PR doesn't change the global handler. It only opts this one mutation out, the way the sign-in, sign-up, forgot-password and add-domain mutations already do.

⚖️ Alternatives

  • Keep mutate and accept a silent failure after unmount. Rejected: that would be a regression, because the global toast had covered that case.
  • Detect "no caller is observing" inside the mutation's onError and toast only then. Rejected: the mutation-level onError runs before any caller's, and the Mutation doesn't expose its observers publicly.
  • Keep the buffered redeploy modal open so it can show the failure inline as well. That would change the buffered path's flow, so it is left for a separate change. This PR keeps its single toast.

🔧 Changes

  • deployComponent.ts: useDeployComponentMutation sets meta: { skipGlobalErrorToast: true } and a mutation-level onError: console.error.
  • DeployFailure.tsx (new): the failure block and its SSH-keys link, moved out of DeployProgress. It takes an optional title and has role="alert".
  • DeployProgress.tsx: renders DeployFailure in place of its inline markup.
  • useImportApplication.ts: holds importFailure in component state, clears it on the next attempt, awaits mutateAsync, toasts only once unmounted, and leaves the editor selection alone after unmount.
  • ImportInstructions.tsx and NewApplication/index.tsx: render importFailure above the Import button.
  • RedeployApplicationModal.tsx: both paths await mutateAsync. The streamed path keeps the deploy that owns the on-screen panel in a ref and toasts only for one that doesn't own it. The buffered path replaces its progress toast with the reason from describeError.

✅ Verification

Route: component and hook tests through the real useDeployComponentMutation and a QueryClient built on the app's own mutationErrorHandler, so the global-toast opt-out is what gets exercised. Only the network edge (instanceClient.post, deployComponentStream) and the toasts are mocked. No browser run: the deploy forms sit behind a signed-in instance, and the failure has to come from a real Harper.

🤖 Generated with Claude Code (Anthropic Claude Opus); posted via @dawsontoth.

Related PRs: #1841 overlaps (stacked base; this PR replaces its global-toast test)
Complexity: medium

Review-Coverage: authored=claude; ran=codex,gemini; blocked=cursor-composer(no-receipt),domain(auth); declined=cursor-grok,cursor-kimi,cursor-muse; rounds=2; full=1 @ f09223e

Review-Attention: study ~9m (raised: degraded review, open major) @ f09223e

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request refactors application deployment and import error handling by introducing a reusable DeployFailure component to display failure messages and handle SSH access failure links. It updates useImportApplication and RedeployApplicationModal to use mutateAsync from React Query, ensuring that deployment outcomes are correctly handled even if the calling component unmounts. It also suppresses global error toasts for these mutations, routing errors to inline panels or context-aware toasts instead, backed by comprehensive tests. Feedback is provided regarding a TypeScript typing issue in RedeployApplicationModal.tsx where useRef<symbol>(undefined) may cause compilation failures under strict null checks; using useRef<symbol>() is recommended instead.

// that settles after the modal was closed or left must not touch whatever is open now, so it
// reports through toasts alone. Set beside the state it mirrors, not from an effect: a deploy can
// fail before the render that shows the panel commits.
const deployOnScreen = useRef<symbol>(undefined);

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.

medium

In TypeScript, initializing useRef<symbol>(undefined) types deployOnScreen.current strictly as symbol and will fail compilation under strict null checks because undefined is not assignable to symbol. Since undefined is later assigned to deployOnScreen.current (on lines 48 and 69), the ref should be typed to allow undefined.

Using useRef<symbol>() is the idiomatic way to define a ref that can hold a symbol or undefined.

Suggested change
const deployOnScreen = useRef<symbol>(undefined);
const deployOnScreen = useRef<symbol>();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declining. Studio is on @types/react 19.3.0, where every useRef overload takes a required initialValue (index.d.ts:1744, :1756, :1768); the third, useRef<T>(initialValue: T | undefined): RefObject<T | undefined>, is the one useRef<symbol>(undefined) resolves to, so the ref is already symbol | undefined and tsc -b is clean. I applied the suggested useRef<symbol>() and ran tsc -b: it fails with TS2554: Expected 1 arguments, but got 0 at line 46, then TS2322: Type 'undefined' is not assignable to type 'symbol' at lines 48 and 69.

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.

@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 72.81% 11963 / 16430
🔵 Statements 72.89% 12740 / 17476
🔵 Functions 67.52% 3162 / 4683
🔵 Branches 68.54% 9399 / 13712
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/instance/applications/components/DeployProgress/DeployFailure.tsx 100% 100% 100% 100%
src/features/instance/applications/components/DeployProgress/DeployProgress.tsx 83.78% 75.94% 83.33% 83.78% 39, 88-120, 252
src/features/instance/applications/components/NewApplication/ImportInstructions.tsx 60% 75% 42.85% 60% 41, 99-136, 170-180
src/features/instance/applications/components/NewApplication/index.tsx 67.56% 60% 83.33% 66.66% 36, 41-42, 49-53, 85-97
src/features/instance/applications/components/NewApplication/useImportApplication.ts 91.42% 81.81% 100% 91.17% 107-111, 113-117, 132-135, 164-165
src/features/instance/applications/modals/RedeployApplicationModal.tsx 74.77% 49.31% 91.66% 74.31% 96, 119-134, 154-161, 165, 171, 189, 203-205, 214-223, 235, 323-333
src/features/instance/config/deployments/components/ActivateStagedRelease.tsx 100% 78.57% 100% 100%
src/integrations/api/instance/applications/deployComponent.ts 100% 100% 100% 100%
Generated in workflow #2263 for commit ab4e737 by the Vitest Coverage Report Action

dawsontoth and others added 5 commits October 12, 2026 14:53
…t beside its form

A failed deploy showed the same failure in two toasts: the deploy mutation's
global one, and the caller's own. The import form's only failure display was a
toast that faded, for failures the user has to act on and retry.

The deploy mutation now opts out of the global toast and keeps its RUM report
with a mutation-level console.error. The import form keeps the failure inline
above the Import button until the next attempt, with the SSH-keys link the
redeploy panel already offered. Redeploy shows a streamed failure in its
progress panel, toasting only when the panel is gone, and the buffered path,
which relied on the global toast, now says why it failed. Both callers await
mutateAsync so a failure that settles after the form unmounts still reaches
the user, and every failure display reads the server's reason through
describeError rather than axios's generic message.

Closes #1833

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is open now

Awaiting mutateAsync means a deploy's continuation now runs after its form or
modal is gone. An import that finished after the user left the form moved the
editor's selection to the new project, and a redeploy that succeeded after its
modal was closed closed and reset whatever redeploy modal was open by then.

The import still refreshes the tree and reports its result, but leaves the
selection alone once the form has unmounted. The redeploy modal records which
deploy its progress panel shows; any other deploy reports through toasts and
never settles the panel or closes the modal.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…omments that narrated it

The cross-model round on the port found the new comments narrating
framework mechanics and caller behavior, and two claims in DeployFailure's
that no longer held (that nothing else reports an SSH failure, and that
RUM drops both halves of its regex). The rule they were circling, that
every deploy caller shows its own failure and awaits mutateAsync, now
lives once in applications/DESIGN.md; the code points there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hat ran it

The closing cross-model round found that switching to another instance's
Applications page mid-import keeps useImportApplication mounted (same
route component), so a mount check let instance A's failure land inline on
instance B's form, with a record link built from B's route params.

The hook now tracks which instance's form is on screen. A failure is shown
inline only on the form of the instance that ran the import, and toasts with
that import's own record action otherwise; the editor selection moves only
for that instance too. Also drops the duplicated DESIGN.md pointers and the
issue-number history from test notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the claude/1833-deploy-failure-inline branch from 5889adc to ab4e737 Compare October 12, 2026 18:55
@dawsontoth
dawsontoth merged commit ab4e737 into stage Oct 12, 2026
4 checks passed
@dawsontoth
dawsontoth deleted the claude/1833-deploy-failure-inline branch October 12, 2026 18:56
dawsontoth added a commit that referenced this pull request Oct 13, 2026
…ailed

The rebase onto stage brought in a write surface this PR's caller audit
predates: activating a staged release (#1876's chain). Its mutation opts
out of the global toast, like import and redeploy since #1871, so an
activation that timed out or got a 504 showed "Failed to activate <app>"
with axios's own text and no warning that it may have gone through.

deployFailureTitle (new, beside the deploy reporters) is the one place a
deploy's failure title is decided: "Couldn't confirm <app> was activated"
for a write with no verdict, "Failed to activate <app>" otherwise. The
import form and the redeploy modal, which the rebase had given their own
copies of that rule, now use it too. reportDeployFailure shows the
unknown-outcome copy, and the activation's progress panel says so as
well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

A failed app import or redeploy shows two error toasts, and the import's only failure display fades

2 participants