Skip to content

fix(react): refresh the cached user after updateMail - #196

Merged
TaprootFreak merged 6 commits into
DFXswiss:developfrom
Danswar:fix/update-mail-refresh-cached-user
Jul 29, 2026
Merged

fix(react): refresh the cached user after updateMail#196
TaprootFreak merged 6 commits into
DFXswiss:developfrom
Danswar:fix/update-mail-refresh-cached-user

Conversation

@Danswar

@Danswar Danswar commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Problem

updateMail in UserContextProvider is a bare passthrough:

const updateMail = useCallback(
  async (mail: string): Promise<void> => {
    return updateMailApi(mail);
  },
  [updateMailApi],
);

Every sibling in the same file — updateUser, verifyMail, renameAddress, updateCallSettings — sets isUserUpdating and writes the returned user back into state. updateMail does neither, and because PUT /v2/user/mail answers with an empty body there is nothing to setUser from: the refreshed user has to be fetched.

Two consequences for consumers:

  1. The cached user.mail stays null after a successful update. A screen that renders its email step on user.mail == null re-renders unchanged and the user submits the same address again. The API requires 2FA once an account carries a mail, so every re-submit comes back 403 TFA_REQUIRED. In production this produced 1206 such 403s in 30 days against 19 successful sets.
  2. isUserUpdating never flips, so a consumer passing it as isLoading to a submit button leaves that button enabled for the whole in-flight request, and a double click fires two concurrent PUTs.

Both were lost in #74, which replaced changeMail with updateMail here. changeMail delegated to updateUser, which set the flag and refreshed the user; the replacement (return updateMailApi(mail);) inherited neither. The downstream consumer switched over six days later in DFXswiss/services#473.

Note: the first commit message in this PR cites #473 for that provenance. That is the consumer-side PR in DFXswiss/services, not the change in this repo — the correct reference is #74, as stated above. Left uncorrected in the commit message because re-signing a pushed commit would require an amend and force-push.

Change

updateMail now sets isUserUpdating, awaits the update, fetches the refreshed user into state, and clears the flag.

Two guards on that refresh, both added after review found the naive version unsafe:

  • The refresh is best-effort. If getUser() fails — or resolves nothing, since an OK response whose body does not parse resolves undefined — the cached user is left as it was and the call still succeeds. The mail has already been changed server-side, so rejecting updateMail would present a success as an error and push the caller back into the re-submit loop this PR exists to remove. The result of the update itself still propagates, so a consumer catching 403 TFA_REQUIRED is unaffected.
  • The previous user object is kept when the address did not move. PUT /v2/user/mail only persists the address when the account has none; once one exists it answers 202 and holds the new address until the emailed code is verified — so the refetched user still carries the old address. A consumer whose effect watches the user object and compares it against a target address would then see a fresh identity with its condition still unmet, call updateMail again, and repeat: an unbounded loop that mails a new verification code every pass and invalidates the previous one. Returning the previous object when nothing changed makes that unreachable.

The signature stays (mail: string) => Promise<void>, so this is behaviour-compatible for existing callers; a caller that ignored the cache before simply gets a correct one now.

Verification

npx lerna run lint, format:check and build all pass across the four packages.

No version bump or CHANGELOG entry: per CONTRIBUTING, releases are cut by the publish workflow and feature PRs must not hand-bump.

Related

updateMail was a bare passthrough to the API hook: unlike updateUser, verifyMail
and every other sibling in this context, it neither refreshed the cached user
nor toggled isUserUpdating. The endpoint answers with an empty body, so the
refreshed user has to be fetched explicitly.

Consumers therefore kept a user whose mail was still null after a successful
update, re-rendered the same "enter your email" step and re-submitted the same
address — which the API answers with 403 TFA_REQUIRED once the account carries a
mail. The missing isUserUpdating also left callers unable to disable their submit
button, so the confirm action stayed clickable while a request was in flight.

Both were lost in the switch from changeMail to updateMail in #473; changeMail
delegated to updateUser and got the refresh and the flag for free.
Danswar added 3 commits July 28, 2026 17:22
The refresh added in the previous commit rejected updateMail when the follow-up
getUser() failed, even though the mail had already been changed server-side.
Consumers would then present a successful change as an error, the user would
re-submit the same address, and the account now carrying a mail would answer 403
TFA_REQUIRED — the exact loop this change set exists to remove, just on a
narrower path. The refresh is now best-effort; the update result itself still
propagates.
…t apply

Review found that the refresh could drive a consumer into an endless update loop.
PUT /v2/user/mail only persists the address when the account has none; once one
exists it answers 202 and holds the new address until the emailed code is
verified, so the refetched user still carries the OLD address. A consumer whose
effect watches the user object and compares it against a target address — which
is exactly what the services widget does for its mail parameter — then sees a new
object identity with the condition still unmet, calls updateMail again, and
repeats. Each pass sends another verification mail and invalidates the previous
code, so the flow can never be completed.

The refresh now keeps the previous object when the address did not move, so an
update that changed nothing cannot re-trigger those effects.
The identity guard adopted the refetched value whenever the address differed,
which included the case where the refetch resolved undefined: an OK response
whose body does not parse resolves that way, and the cached user was then wiped
even though the mail update itself had succeeded. That contradicted the
best-effort contract of this refresh, under which a failed refetch leaves the
user untouched. The previous value is now kept unless a real user came back.
@Danswar

Danswar commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Took 5 review passes to reach zero defects. What changed along the way, since the diff looks much smaller than the path to it:

  1. The initial version rejected updateMail when the follow-up getUser() failed — turning an already-applied mail change into a visible error, which would have pushed the caller straight back into the re-submit loop this PR exists to remove. The refresh became best-effort.
  2. A second reviewer caught the serious one: because PUT /v2/user/mail answers 202 without persisting once an account has an address, the refetched user still carries the old address. Handing consumers a fresh object identity there made a consumer effect that watches user re-fire indefinitely — mailing a new verification code every pass and invalidating the previous one, so the flow could never complete. The refresh now keeps the previous object when the address did not move.
  3. That identity guard then had its own hole: getUser() can resolve undefined (an OK response whose body does not parse), and the guard adopted it, wiping the cached user — inconsistent with the best-effort contract, under which a failed refresh leaves the user untouched.

npx lerna run lint, format:check, build and test pass across all four packages.

@Danswar
Danswar marked this pull request as ready for review July 28, 2026 20:51
Danswar and others added 2 commits July 29, 2026 10:28
Keep the two non-obvious constraints, best-effort and stable identity when the
address did not move, without restating the chain below.
The refresh callback treated a cleared state as "no previous value to compare
against" and wrote the fetched user back. A getUser() response arriving after
the session ended therefore put the signed-out user into the context again,
without a valid token behind it.

updateMail is the only mutation in this file that lacks the `if (!user) return;`
guard its siblings carry, because it was a bare passthrough until this branch
gave it a state write.

@TaprootFreak TaprootFreak 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.

Reviewed with an independent second-model pass and verified locally on a dedicated build host.

Verified

  • lerna run format:check, lint, build — clean across all four packages

Changed on this branch (f9c3fbf)
The refresh callback used !prev as "nothing to compare against" and wrote the fetched user back on that branch. Since the logout effect (user.context.tsx:74-80) sets setUser(undefined), a getUser() response arriving after the session ended put the signed-out user back into the context, with no valid token behind it. That path did not exist before this branch, because updateMail was a bare passthrough with no state write — and it is still the only mutation in the file without the if (!user) return; guard its siblings carry. The condition is now prev && refreshed && refreshed.mail !== prev.mail; the !prev branch had no useful case anyway.

Caveat on the evidence
packages/react has no test script (per CONTRIBUTING, only core and bip322-multisig do), so this fix is covered by compiler and linter but by no runtime test. Adding one would mean introducing test infrastructure to the package first — a separate change, not something to smuggle in here.

Left open deliberately
isUserUpdating is a single shared boolean, so a finally can clear it while a parallel mutation is still in flight. That is the existing pattern of the whole file, which updateMail merely joins; a pending counter belongs in its own PR.

Also confirmed: getUser is memoised through useUser -> useApi, so the added dependency does not rebuild updateMail on every render, and no consumer carries it in an effect dependency list.

Disclosure: I contributed the commit above, so this approval covers my own change as well.

@TaprootFreak

Copy link
Copy Markdown
Contributor

@Danswar Heads-up: I pushed one commit to your branch (f9c3fbf) while reviewing.

The !prev branch in the new setUser guard could resurrect a signed-out user — the logout effect sets setUser(undefined), so a getUser() response landing after logout hit that branch and wrote the old user back without a valid token. Changed it to prev &&; that branch had no useful case anyway.

lerna run format:check, lint and build are green across all four packages. Approved — details in the review.

@TaprootFreak
TaprootFreak merged commit 9462884 into DFXswiss:develop Jul 29, 2026
1 check passed
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.

2 participants