Skip to content

fix: a dropped dispatch or a failed window open is no longer a silent no-op - #1464

Open
ost-ptk wants to merge 12 commits into
developfrom
WALLET-1390-surface-dropped-dispatches
Open

fix: a dropped dispatch or a failed window open is no longer a silent no-op#1464
ost-ptk wants to merge 12 commits into
developfrom
WALLET-1390-surface-dropped-dispatches

Conversation

@ost-ptk

@ost-ptk ost-ptk commented Aug 13, 2026

Copy link
Copy Markdown
Member

Closes WALLET-1390.

Completes WALLET-1364 P1 #2/#3. That work made the in-saga failure loud; two adjacent halves of the same journey stayed silent, so "the user clicks and nothing happens" was still reachable after it shipped.

Why this needed a new surface rather than a sagaError

A sagaError cannot report a transport failure: it lives in the background store, and the background store is precisely what is unreachable when runtime.sendMessage rejects — the dispatch that would report the error is the dispatch that failed.

The replica cannot hold one either. createMainStoreReplica(state) runs in the render body of every app's Tree (popup/index.tsx:50 and four siblings), so a new store is built on every broadcast and any locally dispatched action is discarded on the next one.

So the errors live in a small module store, ui-error-channel.ts, which the already-mounted SagaErrorBanner reads through useSyncExternalStore. One surface, two inputs — not a second banner. It carries a kind rather than a message, because its callers are plain functions with no access to t(); the component owns the copy.

Transport half

dispatchToMainStore now resolves true / false and still never rejects — ~105 call sites do not catch, so rejecting would produce an unhandled rejection at each of them. The log stays universal (type + cause, never the action); the banner is filtered by an allow-list.

SURFACED_DISPATCH_ACTIONS has one written-down criterion: the dispatch IS the sole source of the visible result. Seven actions qualify — the export-keys window (the case from the ticket), lockVault (the wallet stays unlocked, a security outcome rather than an inconvenience), the Ledger window attach, resetVault, accountsImported / accountImported, and dismissSagaError (without it, pressing × on a background row while the transport is down does nothing and explains nothing). A banner on all ~105 sites — theme changes and the like — would train the user to ignore the banner.

The list is built from the creators' .type (a rename is a compile error) and pinned by a test, so growing or shrinking it is a deliberate edit. It adds no import cycle — redux/utils.ts already imports redux-action.ts, which pulls every slice's actions — verified with dependency:circular (140 before, 140 after) and import/no-cycle.

Nine call sites that reported failure as success

Chaining .then was only half of it; the paths that dispatch and continue without chaining had the same bug.

Site On a dropped dispatch, before
reset-vault closeWindowByReloadExtension() reloaded the extension, presenting an unperformed vault reset as complete and taking the banner down with the page
onboarding/welcome advanced to create-password on a vault that was never reset
connected-ledger navigated Home with nothing imported
import-account-with-file-upload navigated to its success screen for an import that never reached the vault
import-account-from-torus set its success step, same
onboarding/create-secret-phrase back button navigated to create-password with the generated phrase still alive — the user would set a password over a phrase they never recorded
layout/error/window-page tore the window down, then dropped the user into onboarding believing the vault was reset

All now branch on the verdict. onboarding/reset-wallet needs no guard: it dispatches and navigates nowhere, so the banner is the whole fix there.

The error page also loses its try/catch: all three calls inside it were unawaited, so it never caught anything.

UI half

The two identical .catch(e => console.error(e)) handlers around openWindow are gone. Reporting moved into useWindowManager, so the returned openWindow never rejects and a third call site cannot reintroduce the swallow. It logs error.name, not the rejection: the URL windows.create is given embeds OpenWindowProps.searchParams, where a sign-message plaintext can ride on other window paths, and semgrep's cw-logging-secrets pattern would not catch that.

The copy says only what is true

One string serves five unrelated actions, so it claims neither that nothing changed nor that retrying helps. Both would be false somewhere: handleReduxAction dispatches resetVault into the store and only then awaits enableOnboardingFlow, so a rejection can arrive with the vault already wiped; and use-ledger sets triggeredRef.current immediately after its dispatch, so that effect cannot re-run. It reads: The wallet didn't respond. Your last action may not have been applied.

Each kind renders a literal <Trans> rather than a t() lookup keyed on kind — a dynamic key is invisible to i18next-parser, so the strings would never reach a catalog. No extraction run here (lang/ is far behind); they ship with their English fallback.

Two import changes, and why they are here

SagaErrorBanner takes its layout primitives from @libs/layout/containers and its SvgIcon / Typography directly, and typography.tsx takes Skeleton from its sibling instead of from the @libs/ui/components barrel — a barrel importing itself back, which is what containers.ts warns about in its own header comment ("cause huge problems with webpack bundle and lead to blank popups").

That was also what made the component untestable: the barrels transitively load webextension-polyfill, which throws on import outside an extension, and mac-scrollbar, which ships only an ESM entry with no main that jest cannot resolve. It is why no barrel-importing component in this repo has had a test. Breaking the chain was cheaper and more useful than three mocks plus a jest.config.js change — and since it touches a component used app-wide, it is verified against a production build:chrome.

Tests

  • ui-error-channel — dedupe by key, snapshot-reference stability (a fresh array each call would re-render forever), no allocation on a deduped repeat, subscribe/unsubscribe, re-report after dismiss, server snapshot.
  • SURFACED_DISPATCH_ACTIONS — pin, checked by mutation: dropping an entry fails the test.
  • dispatchToMainStoretrue on success, false instead of a rejection, log shape, allow-listed action surfaces, non-allow-listed action logs only.
  • SagaErrorBanner — rendered with renderToStaticMarkup for each channel separately and both at once, asserting the action type never reaches the markup.

ci-check green: 87 suites, 763 tests, knip clean.

The dispatch→banner seam is covered by e2e

e2e-tests/popup/error-surface/dropped-dispatch.spec.ts drives the real extension: it breaks the page's transport to the background, clicks the menu item, and asserts the banner. Four cases — banner on a dropped export-keys dispatch (and that no window opens), no banner for a non-surfaced action, dismiss-then-reappear, and the failed window open. 4/4 green in ~13s.

Three things that had to be established by running it, and are recorded in the file:

  • The break has to be a rejected promise, not a synchronous throw. Current Chrome exposes browser natively, so webextension-polyfill re-exports it instead of wrapping chrome, and the app calls the patched function directly. A throw escapes synchronously out of dispatchToMainStore and trips the ErrorBoundary — the popup renders "Something went wrong" and no banner is involved at all.
  • Stopping the service worker does not reproduce this. In MV3 an incoming message is itself the event that cold-starts a stopped worker, and while the vault is unlocked the keep-alive alarm wakes it within 30s regardless.
  • unlockVault returns before the unlock finishes (it waits only for network idle; the flow completes through further dispatches from the page), so the transport must not be broken until the unlocked UI is up.

One finding worth a decision, not fixed here

While a banner row is visible it covers the header, including the menu button. Measured: the banner is position: fixed; top: 0 at a tooltip z-index, occupying 1280×77 over a menu icon at y=24..48, and elementFromPoint at the icon's centre returns the banner. So the user cannot reopen the menu to retry until they dismiss the row — the × is right there, so it is one extra click rather than a dead end.

This is pre-existing SagaErrorBanner layout, not something this PR introduces, but this PR is what makes the banner routine rather than rare. Fixing it means either pushing page content down by the banner height or moving the banner out of the header's strip — a visual change worth a deliberate call rather than a drive-by. It is also why the dedupe case lives in the unit test instead of e2e: a second menu click cannot land while the row is up.

Deliberately left out

  • loginRetryLockoutTimeSet (use-lock-wallet-when-no-more-retries.ts:26) is the dispatch that actually enforces the brute-force lockout, and it is not surfaced. A banner would tell the truth and change nothing: the effect's deps cannot change, so there is no retry path, and the real fix is not letting the lockout depend on a droppable dispatch. Worth its own ticket.
  • Layering: redux/utils.ts now imports from @libs/ui/..., so the channel would ride into the service-worker bundle. Not reachable today — no background module calls dispatchToMainStore — but it inverts the background→UI direction. Fixing it properly means injecting the reporter, which is more machinery than the problem currently justifies.
  • No change to the app-events slice or SagaErrorSource: local errors deliberately do not survive a reload.

ost-ptk and others added 11 commits August 13, 2026 13:40
…sees

A `sagaError` cannot report a transport failure: it lives in the background
store, and the background store is precisely what is unreachable when
`runtime.sendMessage` rejects. The replica cannot hold one either —
`createMainStoreReplica(state)` runs in the render body of every app's `Tree`, so
a local dispatch is discarded on the next broadcast.

Hence a module store, read directly by the banner. It carries a `kind` rather
than a message because its callers are plain functions with no access to `t()`;
the component owns the copy. Reports dedupe by `kind:detail`, since a restarting
service worker plus three impatient clicks is the normal shape of this failure
and the popup is too narrow for three identical rows.

`getUiErrorsSnapshot` returns a reference that changes only when the list does
(`useSyncExternalStore` otherwise re-renders on every check), and
`getUiErrorsServerSnapshot` exists because this repo renders components through
`renderToStaticMarkup`, where the hook throws without it.
Logging every dropped dispatch is right; showing a banner for every one is not.
`dispatchToMainStore` has ~105 call sites, and a banner on a dropped theme change
or a dropped banner-dismissal would train the user to ignore the banner.

So membership has one criterion, written down next to the list: the dispatch IS
the sole source of the visible result — if it never lands, the user sees nothing
happen and no other feedback path exists. Five actions qualify: the export-keys
window (the case from the ticket), `lockVault` (the wallet stays unlocked, which
is a security outcome), the Ledger window attach (the request hangs until the
dapp times out), and `resetVault` / `accountsImported`, whose `.then` callbacks
would otherwise present unperformed work as done.

Built from the creators' `.type`, so a rename is a compile error, and pinned by a
test so growing or shrinking the list is a deliberate edit rather than a side
effect. Adds no import cycle: `redux/utils.ts` already imports `redux-action.ts`,
which pulls every slice's actions.
`dispatchToMainStore` logged the failure and returned a promise that resolved
either way, so a caller could not tell a dispatch that ran and failed from one
that never arrived — and the user was told nothing in either case.

It now resolves `true` / `false` and, for an allow-listed action, reports to the
UI-local error channel. It still never rejects: ~105 call sites do not catch, so
rejecting would produce an unhandled rejection at each of them. The log stays
universal — type and cause, never the action, whose payload carries vault and
signature material.

The `Promise<boolean>` widening is source-compatible with every fire-and-forget
caller; `tsc` over the whole tree confirms nothing else depended on the old type.
…dropped dispatch

Three call sites chained `.then` onto `dispatchToMainStore` and ran their success
path unconditionally, so a dispatch that never reached the background still
navigated or reloaded as if the work had happened.

The reset one was the worst of the three: `closeWindowByReloadExtension()`
reloaded the extension, presenting an unperformed vault reset as complete and
taking the error banner down with the page. Onboarding advanced to the
create-password step on a vault that was never reset; the Ledger flow navigated
Home with no accounts imported.

All three now branch on the verdict. Each of the two actions involved is
allow-listed, so the user gets the banner instead of a false success.
Both `openWindow` call sites ended in the same `.catch(e => console.error(e))`:
the window failed to open, the record went somewhere no end user can read, and
the UI showed nothing at all.

The reporting moves into `useWindowManager` rather than being repeated at the two
sites, so the returned `openWindow` never rejects and a third call site added
later cannot reintroduce the swallow. No allow-list is involved here — a window
that did not open is always visible to the user by its absence.
One surface, two inputs: background `sagaError`s keep arriving through the replica
store, and the UI-local channel is read through `useSyncExternalStore`. The copy
for the local rows lives here, where `t()` does, because the channel carries a
`kind` — unlike `SagaError.message`, which is produced in the background and
rendered verbatim and untranslated. The action type and the window name stay in
the console: they name no dapp and suggest no next step.

Two imports changed to reach leaves instead of barrels: the component now takes
its layout primitives from `@libs/layout/containers` and its `SvgIcon` /
`Typography` directly, and `typography.tsx` takes `Skeleton` from its sibling
rather than from `@libs/ui/components` — a barrel importing itself back, which is
what `containers.ts` warns about in its own header comment ("cause huge problems
with webpack bundle and lead to blank popups").

That was also what made the component untestable: the barrels transitively load
`webextension-polyfill`, which throws on import outside an extension, and
`mac-scrollbar`, which ships only an ESM entry that jest cannot resolve — the
reason no barrel-importing component in this repo has ever had a test. With the
chain broken, the suite renders the banner for each channel and for both at once.
Verified against a production `build:chrome`.
… success

Fixing the three chained `.then` sites left the same failure live on the paths
that dispatch and continue without chaining anything — including the two most
used import flows, where only the Ledger one had been covered.

- `accountImported` was missing from the surfaced list entirely (only its plural
  sibling was there), and both of its call sites showed success unconditionally:
  the secret-key-file flow navigated to its success screen and the Torus flow set
  its success step for an import that never reached the vault.
- `resetVault` has five call sites, and two more needed guarding. The error page
  ran `dispatch; closeCurrentWindow(); openOnboardingUi();` — tearing the window
  down destroys the banner it just queued and drops the user into onboarding
  believing the vault was reset. Onboarding's secret-phrase back button navigated
  to create-password on a vault that was never wiped, so the generated phrase
  survived and the user would set a password over a phrase they never recorded.
- `reset-wallet` needs no guard: it dispatches and navigates nowhere, so the
  banner is the whole fix there.

Also drops the `try/catch` around the error-page teardown. It caught nothing —
all three calls inside it were unawaited, so nothing could throw synchronously.

Adds `dismissSagaError`: without it, pressing × on a background row while the
transport is down does nothing and explains nothing. With it the reason appears
as one deduped row, whose own × is local and always works.
…tracted

One string serves five unrelated actions, and the old wording asserted two things
the boolean cannot guarantee.

"Nothing was changed" is provably false in at least one path:
`handleReduxAction` dispatches `resetVault` into the store and only then awaits
`enableOnboardingFlow`, so a rejection can arrive with the vault already wiped.
"Please try again" is false for the Ledger attach: `use-ledger` sets
`triggeredRef.current` immediately after its dispatch, so that effect cannot
re-run, and the permission window is open either way.

The copy now says only what is true for all of them — the wallet did not respond,
so the last action may not have been applied.

It also moves from a `t()` lookup keyed on `kind` to a literal `<Trans>` per kind:
a dynamic key is invisible to i18next-parser, so those strings would never have
reached any catalog, which contradicted the very comment distinguishing them from
the untranslated saga messages.

Drops two mocks and the comment justifying them from the suite: the same commit
that added them switched the component to direct leaf imports, so the barrels are
no longer pulled. Re-ran the suite without them — all five still pass.
`windows.create` is given a URL built from `OpenWindowProps.searchParams`, so
logging the raw rejection can put those params in the console. A sign-message
plaintext rides in that query string on other window paths, and the semgrep
`cw-logging-secrets` pattern would not catch it.

No current UI caller passes `searchParams`, so this is latent — but the hook's
public type invites one. Logs `error.name` instead, matching what `use-ledger.ts`
already does for the same reason.
The unit tests sat on either side of the seam — one asserts `reportUiError` is
called, the other that such a call renders a row — but nothing drove a real
dropped dispatch in a real popup. This does: it breaks the page's transport,
clicks the menu item, and asserts the banner.

Four things had to be learned the hard way and are recorded in the file:

- The break must be a *rejected promise*, not a synchronous throw. Current
  Chrome exposes `browser` natively, so `webextension-polyfill` re-exports it
  rather than wrapping `chrome`, and the app calls the patched function directly.
  A `throw` escapes synchronously out of `dispatchToMainStore` and trips the
  ErrorBoundary — the whole popup renders "Something went wrong" and no banner
  is ever involved.
- Stopping the service worker does not reproduce this at all: in MV3 an incoming
  message is the event that cold-starts a stopped worker, and the keep-alive
  alarm wakes it within 30s while the vault is unlocked.
- `unlockVault` returns before the unlock has finished (it only waits for network
  idle, and the flow completes through further dispatches from the page), so the
  transport must not be broken until the unlocked UI is up.
- Dedupe is deliberately left to the unit test: the banner is `position: fixed;
  top: 0` at a tooltip z-index, so a visible row covers the header — measured
  1280x77 over a menu icon at y=24..48, with `elementFromPoint` returning the
  banner — and a second menu click cannot land until it is dismissed.

Covers: banner on a dropped export-keys dispatch (and that no window opens),
no banner for a non-surfaced action, dismiss-then-reappear, and the failed
window open.
@ost-ptk
ost-ptk requested a review from Comp0te August 13, 2026 12:00
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