diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 69bee08b22..d2bc3d7f63 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -429,9 +429,15 @@ function CommunityApp({ const isCommunitySwitch = hasSwitchedCommunityRef.current; const isContinuingOnboarding = continueOnboarding && !isCommunitySwitch; + const transaction = communityOnboarding.transaction; + const communityInitAttempt = + transaction && transaction.communityId === activeCommunity?.id + ? (transaction.initAttempt ?? 0) + : -1; const community = useCommunityInit( activeCommunity, communityKey, + communityInitAttempt, sharedIdentity, isFindingCommunityAfterLeave, communities, @@ -536,7 +542,6 @@ function CommunityApp({ const bootSplashPhase = useBootSplashHold(); - const transaction = communityOnboarding.transaction; useEffect(() => { if (transaction?.stage !== "connecting") { connectingTransactionRef.current = null; @@ -546,7 +551,8 @@ function CommunityApp({ const targetIsReady = transaction?.communityId === activeCommunity?.id && community.isReady && - community.appliedKey === communityKey; + community.appliedKey === communityKey && + community.initAttempt === communityInitAttempt; const enterpriseProfile = community.isReady ? community.enterpriseProfile : null; @@ -554,6 +560,8 @@ function CommunityApp({ useEffect(() => { if ( transaction?.stage !== "connecting" || + !("initAttempt" in community) || + community.initAttempt !== communityInitAttempt || !("appliedKey" in community) || community.appliedKey !== communityKey || !("error" in community) || @@ -565,7 +573,13 @@ function CommunityApp({ { stage: "corporate-profile", error: community.error }, transaction.id, ); - }, [community, communityKey, communityOnboarding, transaction]); + }, [ + community, + communityInitAttempt, + communityKey, + communityOnboarding, + transaction, + ]); useEffect(() => { if (transaction?.stage !== "connecting" || !targetIsReady) return; const transactionId = transaction.id; diff --git a/desktop/src/features/communities/useCommunityInit.test.mjs b/desktop/src/features/communities/useCommunityInit.test.mjs index 20b08b8072..45e2b65f93 100644 --- a/desktop/src/features/communities/useCommunityInit.test.mjs +++ b/desktop/src/features/communities/useCommunityInit.test.mjs @@ -93,9 +93,12 @@ function neverSettles() { } function mount(communities) { - return renderHook((list) => useCommunityInit(b, "b", false, false, list), { - initialProps: communities, - }); + return renderHook( + (list) => useCommunityInit(b, "b", null, false, false, list), + { + initialProps: communities, + }, + ); } test("useCommunityInit gates enterprise login before applying the community", async () => { @@ -122,7 +125,7 @@ test("useCommunityInit gates enterprise login before applying the community", as try { const community = testCommunity(); const hook = renderHook(() => - useCommunityInit(community, "community-key", false, true), + useCommunityInit(community, "community-key", null, false, true), ); await waitFor(() => assert.equal(hook.result.current.isReady, true)); @@ -182,7 +185,7 @@ test("useCommunityInit blocks community apply when enterprise login gate fails", try { const hook = renderHook(() => - useCommunityInit(testCommunity(), "community-key", false, true), + useCommunityInit(testCommunity(), "community-key", null, false, true), ); await waitFor(() => @@ -244,7 +247,8 @@ test("useCommunityInit cancels an owned pending enterprise authentication login try { const hook = renderHook( - ({ community, key }) => useCommunityInit(community, key, false, true), + ({ community, key }) => + useCommunityInit(community, key, null, false, true), { initialProps: { community: communityA, key: "community-a-key" } }, ); @@ -321,7 +325,7 @@ test("useCommunityInit waits for explicit enterprise browser consent", async () try { const community = testCommunity({ name: "Block Buzz" }); const hook = renderHook(() => - useCommunityInit(community, "community-key", false, true), + useCommunityInit(community, "community-key", null, false, true), ); await waitFor(() => assert.ok("enterpriseLogin" in hook.result.current)); @@ -392,7 +396,7 @@ test("useCommunityInit exposes authoritative enterprise profile when both identi try { const hook = renderHook(() => - useCommunityInit(testCommunity(), "community-key", false, true), + useCommunityInit(testCommunity(), "community-key", null, false, true), ); await waitFor(() => assert.ok("enterpriseLogin" in hook.result.current)); await act(async () => { diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 129d57ebb1..048ee35396 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -114,6 +114,7 @@ type CommunityInitResult = isReady: true; needsSetup: false; appliedKey: string; + initAttempt: number | null; identityPubkey: string | null; enterpriseProfile: EnterpriseProfileSeed | null; } @@ -122,11 +123,17 @@ type CommunityInitResult = needsSetup: true; defaultRelayUrl: string; } - | { isReady: false; needsSetup: false; appliedKey: string | null } | { isReady: false; needsSetup: false; appliedKey: string | null; + initAttempt: number | null; + } + | { + isReady: false; + needsSetup: false; + appliedKey: string | null; + initAttempt: number | null; enterpriseLogin: { communityName: string; error: string | null; @@ -138,6 +145,7 @@ type CommunityInitResult = isReady: false; needsSetup: false; appliedKey: string | null; + initAttempt: number | null; error: string; }; @@ -152,6 +160,7 @@ type CommunityInitResult = export function useCommunityInit( activeCommunity: Community | null, communityKey: string, + initAttempt: number | null, isSharedIdentity: boolean, suppressAutoConnect = false, communities: readonly Community[] = [], @@ -161,6 +170,7 @@ export function useCommunityInit( isReady: false, needsSetup: false, appliedKey: null, + initAttempt: null, }); const [enterpriseLoginPrompt, setEnterpriseLoginPrompt] = useState<{ communityName: string; @@ -200,6 +210,7 @@ export function useCommunityInit( isReady: false, needsSetup: false, appliedKey: null, + initAttempt: null, error: "Could not refresh avatar source permissions. Reload to retry.", }); }); @@ -242,6 +253,7 @@ export function useCommunityInit( isReady: false, needsSetup: false, appliedKey: null, + initAttempt: null, error: error instanceof Error ? `Could not safely leave community: ${error.message}` @@ -317,11 +329,14 @@ export function useCommunityInit( // Mark this community config as pending while it is applied to the // backend. App.tsx also checks appliedKey against the active communityKey, // which prevents rendering community-scoped UI for a new community until - // that exact config has finished applying. + // that exact config has finished applying. The init attempt is captured + // from the visible onboarding transaction so retry generations can fence + // stale completions for the same community config. setResult({ isReady: false, needsSetup: false, appliedKey: communityKey, + initAttempt, }); // Resolve the active signer before resetting singletons so @@ -367,6 +382,7 @@ export function useCommunityInit( isReady: false, needsSetup: false, appliedKey: null, + initAttempt: null, error: error instanceof Error ? `Could not safely switch communities: ${error.message}` @@ -418,6 +434,7 @@ export function useCommunityInit( isReady: false, needsSetup: false, appliedKey: communityKey, + initAttempt, error: errorMessage, }); } @@ -470,6 +487,7 @@ export function useCommunityInit( isReady: false, needsSetup: false, appliedKey: null, + initAttempt: null, error: error instanceof Error ? error.message @@ -506,6 +524,7 @@ export function useCommunityInit( isReady: true, needsSetup: false, appliedKey: communityKey, + initAttempt, identityPubkey, enterpriseProfile: enterpriseProfileForResult, }); @@ -538,6 +557,7 @@ export function useCommunityInit( isSharedIdentity, suppressAutoConnect, communityKey, + initAttempt, ]); if (enterpriseLoginPrompt !== null) { @@ -545,6 +565,7 @@ export function useCommunityInit( isReady: false, needsSetup: false, appliedKey: activeCommunity ? communityKey : null, + initAttempt: activeCommunity ? initAttempt : null, enterpriseLogin: { ...enterpriseLoginPrompt, onCancel: cancelEnterpriseLogin, diff --git a/desktop/src/features/onboarding/communityOnboarding.test.mjs b/desktop/src/features/onboarding/communityOnboarding.test.mjs index e9645b191e..fd5802cce1 100644 --- a/desktop/src/features/onboarding/communityOnboarding.test.mjs +++ b/desktop/src/features/onboarding/communityOnboarding.test.mjs @@ -6,6 +6,7 @@ import { isTransactionStillConnecting, loadCommunityOnboardingTransaction, markCommunityOnboardingComplete, + nextCommunityOnboardingInitAttempt, resolveProfileCheckAction, shouldSkipCommunityOnboarding, startCommunityOnboarding, @@ -130,6 +131,48 @@ test("acknowledgment persists but resets when the same-relay link reopens", () = assert.equal(reopened.acknowledged, undefined); }); +test("retry generation advances before stale same-community results can match", () => { + const storage = createMemoryStorage(); + const transaction = startCommunityOnboarding( + { source: "first-community", relayUrl: "wss://relay.example" }, + storage, + ); + assert.equal(transaction.initAttempt, 0); + + const retryAttempt = nextCommunityOnboardingInitAttempt(transaction); + const retried = updateCommunityOnboardingTransaction( + transaction, + { stage: "connecting", initAttempt: retryAttempt, error: undefined }, + storage, + ); + + assert.equal(retryAttempt, 1); + assert.equal(retried.initAttempt, 1); + assert.equal(loadCommunityOnboardingTransaction(storage)?.initAttempt, 1); + assert.notEqual(retried.initAttempt, transaction.initAttempt); +}); + +test("same-relay ingress resumes with a fresh init generation", () => { + const storage = createMemoryStorage(); + const transaction = startCommunityOnboarding( + { source: "deep-link-connect", relayUrl: "wss://relay.example" }, + storage, + ); + updateCommunityOnboardingTransaction( + transaction, + { stage: "corporate-profile", error: "Browser login was rejected" }, + storage, + ); + + const reopened = startCommunityOnboarding( + { source: "deep-link-connect", relayUrl: "wss://relay.example" }, + storage, + ); + + assert.equal(reopened.id, transaction.id); + assert.equal(reopened.initAttempt, 1); + assert.equal(reopened.error, undefined); +}); test("malformed persisted state is ignored and can be cleared", () => { const storage = createMemoryStorage({ "buzz-community-onboarding-transaction.v1": '{"stage":"profile"}', diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx index 87c928cc2d..bf3c1e5a28 100644 --- a/desktop/src/features/onboarding/communityOnboarding.tsx +++ b/desktop/src/features/onboarding/communityOnboarding.tsx @@ -36,6 +36,12 @@ export type CommunityOnboardingTransaction = { /** First-run screen that launched this transaction, restored on cancel. */ firstCommunityPage?: FirstCommunityPage; stage: CommunityOnboardingStage; + /** + * Frontend-owned init generation. Incremented before a retry becomes a + * visible connecting transaction so late results from the previous init + * cannot mutate the retried transaction. + */ + initAttempt?: number; relayUrl: string; inviteCode?: string; communityName: string; @@ -62,6 +68,7 @@ export type CommunityOnboardingTransactionPatch = Partial< Pick< CommunityOnboardingTransaction, | "stage" + | "initAttempt" | "relayUrl" | "communityId" | "previousCommunityId" @@ -167,6 +174,7 @@ export function startCommunityOnboarding( policyReceipt: input.policyReceipt ?? existing.policyReceipt, updatedAt: now.toISOString(), error: undefined, + initAttempt: (existing.initAttempt ?? 0) + 1, // A freshly opened link deserves fresh feedback — re-present the gate // even if a previous link for this relay was already dismissed. acknowledged: undefined, @@ -189,11 +197,18 @@ export function startCommunityOnboarding( policyReceipt: input.policyReceipt, createdAt: timestamp, updatedAt: timestamp, + initAttempt: 0, }; saveCommunityOnboardingTransaction(transaction, storage); return transaction; } +export function nextCommunityOnboardingInitAttempt( + transaction: CommunityOnboardingTransaction, +): number { + return (transaction.initAttempt ?? 0) + 1; +} + export function updateCommunityOnboardingTransaction( transaction: CommunityOnboardingTransaction, patch: CommunityOnboardingTransactionPatch, diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index d1997e7541..50d5b6d135 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -4,6 +4,7 @@ import { Plus, Users } from "lucide-react"; import { markCommunityOnboardingComplete, + nextCommunityOnboardingInitAttempt, type CommunityOnboardingStage, useCommunityOnboarding, } from "@/features/onboarding/communityOnboarding"; @@ -274,11 +275,10 @@ export function CommunityOnboardingFlow({ }, [clear, isEnteringStage]); const retry = () => { + if (!transaction) return; update({ - stage: nextRetryStage( - transaction?.stage, - Boolean(transaction?.inviteCode), - ), + stage: nextRetryStage(transaction.stage, Boolean(transaction.inviteCode)), + initAttempt: nextCommunityOnboardingInitAttempt(transaction), error: undefined, }); onRetry?.();