Skip to content

fix(desktop): defer the first-run profile write until membership exists - #4837

Open
Tr1ckyMag1ca1 wants to merge 2 commits into
block:mainfrom
Tr1ckyMag1ca1:fix/defer-profile-until-member
Open

fix(desktop): defer the first-run profile write until membership exists#4837
Tr1ckyMag1ca1 wants to merge 2 commits into
block:mainfrom
Tr1ckyMag1ca1:fix/defer-profile-until-member

Conversation

@Tr1ckyMag1ca1

Copy link
Copy Markdown

Fixes #3544.

Stacked on #4821 — review that one first. This branch is built on top of it, so the diff below includes #4821's commit (fix(desktop): recognize the membership gate's bodyless 404 in onboarding). The dependency is real: this imports the shared isRelayMembershipDeniedError that #4821 introduces. Base is main only because GitHub does not allow a fork branch as a cross-fork base. Review only the second commit here, and merge #4821 first — this rebases cleanly once it lands.

The deadlock

First-run setup asks for a display name and avatar before the user has joined any community. A kind:0 write requires relay membership, so the save is refused and setup dead-ends. Per #3544 this was the single largest drop-off on a live self-hosted community — corroborated there by four members left with no display_name, which is what you'd expect from people who hit the wall and gave up.

The key point: at that moment membership is unsatisfiable. The user has nothing to be a member of yet. Every affordance on the denied screen — retry, import a key, change community — assumes a community they are trying to get into. First-run users have none, so the refusal is terminal.

Note this is not fixed by deferring only the profile write: update_profile (desktop/src-tauri/src/commands/profile.rs) is a read-merge-write and calls query_relay first, so the gate rejects the read. #4821 covers recognizing that refusal; this PR covers surviving it.

Approach

Stop treating the refusal as terminal — park what the user typed and replay it once membership exists.

features/profile/pendingProfileSave.ts (new) mirrors the existing avatarProfileSync deferred-write pattern, with one deliberate difference: it persists to localStorage. Joining a community remounts the community-scoped React tree, and the user may quit and relaunch before joining, so in-memory state would not survive the gap. (Relaunch-then-reclick is exactly how one user escaped this in practice.)

Safety properties, each covered by a test:

  • Identity-scoped. A different active pubkey discards the value rather than writing one user's profile under another's key.
  • Keeps waiting when membership still is not established, or when no identity resolves yet.
  • Drops on non-membership failure, so a malformed value cannot wedge every later boot with a permanent retry.
  • Storage failures never propagate — losing a deferred value beats failing the step the user is trying to get past.

Flush point: the community-ready transition in useCommunityInit, the first moment the write can succeed. Failures there can never block readiness.

MembershipDenied gains an optional onContinueWithoutProfile"Finish this after I join" — supplied only by first-run onboarding. The invite flow omits the prop and is visually unchanged.

OnboardingFlow: both membership-denied branches now route through one enterMembershipDenied helper that parks the draft, replacing duplicated inline handling.

Community-switching contract

resetPendingProfileSave() is registered in resetCommunityState() and documented in AGENTS.md, per the module-singleton rule. The persisted value deliberately survives the reset — it is keyed to an identity, not a community, and exists precisely to outlive the remount that joining causes. Only the in-memory instance is dropped.

Testing

  • just desktop-ci — green (exit 0).
  • 12 new unit tests in pendingProfileSave.test.mjs covering flush outcomes, identity mismatch (including case-insensitive comparison), deferral, discard-on-hard-failure, malformed/foreign storage contents, and throwing storage.

Not covered: the OnboardingFlow / MembershipDenied wiring has no component test — the repo tests this layer via Playwright, and a spec for "denied → continue → join → profile appears" needs a relay-backed fixture. Worth adding; flagging rather than silently skipping.

Follow-up, deliberately not in scope

The invite flow (CommunityOnboardingFlow) does not park its draft on denial. Its profile step runs after the community is applied, so the flush has already happened by then and parking would only pay off on a later community switch. Straightforward to add if reviewers want symmetry.

Onboarding classified "not a relay member" with a predicate that was
duplicated in both flows and matched only message-bearing rejections:
"You must be a relay member", "relay_membership_required",
"restricted: not a relay member", "invalid: you are not a relay member".

The relay's membership gate emits none of those. It answers with a
bodyless 404 (api::relay_members, MembershipDecision::Denied), which
classify_relay_error renders as the status-only string "relay returned
404 Not Found" with no detail suffix.

So a user who reached the profile step without relay membership got that
raw string printed under the username field and no way forward, instead
of the MembershipDenied screen and its Retry / Import key / Change
community affordances. It bites at the profile step specifically because
update_profile is a read-merge-write: query_relay runs before any write,
so the gate rejects the read.

Move the predicate to shared/lib/relayError.ts alongside
isRelayUnreachableError, teach it the bodyless-404 form, and have both
onboarding flows import it.

The 404 match is exact rather than a prefix. Every other relay 404 is an
api_error carrying {"error": "<msg>"}, which renders with a ": <detail>"
suffix — a host with no community bound is not a membership failure and
must keep routing elsewhere. desktop/src/shared/api/tauri.ts already
relies on this same equivalence.

This restores the recovery path; it does not reorder onboarding, so the
underlying first-run deadlock in block#3544 remains open.

Refs block#3544

Signed-off-by: Tr1ckyMag1ca1 <150954627+Tr1ckyMag1ca1@users.noreply.github.com>
First-run setup asks for a display name and avatar before the user has
joined any community. A kind:0 write requires relay membership, so the
save is refused and onboarding dead-ends — the largest observed drop-off
on a live self-hosted community (block#3544).

Membership cannot be satisfied at that point: the user has nothing to be
a member of yet. So stop treating the refusal as terminal and defer the
write instead.

- New `features/profile/pendingProfileSave.ts` parks what the user typed
  and replays it once membership exists. It mirrors `avatarProfileSync`,
  but persists to localStorage: joining remounts the community-scoped
  tree, and the user may quit and relaunch before joining, so in-memory
  state would not survive the gap.
- The parked value is identity-scoped. A different active pubkey discards
  it rather than writing one user's profile under another's key.
- Flush runs at the community-ready transition in useCommunityInit — the
  first moment the write can succeed. It keeps the value parked if
  membership still has not landed, and drops it on a non-membership
  failure so a bad value cannot wedge every later boot.
- MembershipDenied gains an optional "Finish this after I join" exit,
  supplied only by first-run onboarding. The invite flow omits it: a user
  mid-claim has a community to get into, and the existing actions are the
  way in.
- Both membership-denied branches in OnboardingFlow now route through one
  helper that parks the draft, replacing duplicated inline handling.

`resetPendingProfileSave()` is registered in `resetCommunityState()` per
the community-switching contract; the persisted value deliberately
survives, since it is keyed to an identity rather than a community and
exists precisely to outlive the remount.

Fixes block#3544

Signed-off-by: Tr1ckyMag1ca1 <150954627+Tr1ckyMag1ca1@users.noreply.github.com>
@Tr1ckyMag1ca1
Tr1ckyMag1ca1 requested a review from a team as a code owner August 5, 2026 04:30
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.

First-run deadlock: profile save 404s because it requires community membership, but setup asks for the profile first

1 participant