coder 0
2026-09-29 12:46:39 -06:00
committed by bb-expert
parent 6ee09c0f7b
commit f4460aff97
6 changed files with 114 additions and 17 deletions

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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"}',

View File

@@ -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,

View File

@@ -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?.();