From 5dd2d952136bfa29629ab879c02c91a879a15647 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 25 Jun 2026 18:33:02 +0700 Subject: [PATCH] fix(p2p): stop account recovery reload loop --- src/__tests__/app.test.tsx | 3 + ...-browser-pure-p2p-account-upgrade.test.tsx | 63 ++++++++++++++----- .../use-browser-pure-p2p-account-upgrade.ts | 50 ++++++++------- src/lib/__tests__/p2p-browser-config.test.ts | 5 +- src/lib/p2p-browser-config.ts | 13 +++- 5 files changed, 92 insertions(+), 42 deletions(-) diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index c7fa4d18..7a02a53d 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -32,6 +32,7 @@ const testState = vi.hoisted(() => ({ shouldShowSnow: true, createAccountMock: vi.fn().mockResolvedValue(undefined), removeSnowMock: vi.fn(), + setActiveAccountMock: vi.fn().mockResolvedValue(undefined), setAccountMock: vi.fn().mockResolvedValue(undefined), replyModalState: { activeCid: null, @@ -52,6 +53,7 @@ const testState = vi.hoisted(() => ({ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ createAccount: () => testState.createAccountMock(), + setActiveAccount: (accountName: string) => testState.setActiveAccountMock(accountName), setAccount: (account: unknown) => testState.setAccountMock(account), useAccount: () => testState.account, useAccounts: () => ({ accounts: testState.account ? [testState.account] : [] }), @@ -371,6 +373,7 @@ describe('App', () => { testState.communities = {}; testState.useThemeMock.mockReset(); testState.createAccountMock.mockReset().mockResolvedValue(undefined); + testState.setActiveAccountMock.mockReset().mockResolvedValue(undefined); testState.setAccountMock.mockReset().mockResolvedValue(undefined); testState.closeCreateBoardModalMock.mockReset(); testState.initSnowMock.mockReset(); diff --git a/src/hooks/__tests__/use-browser-pure-p2p-account-upgrade.test.tsx b/src/hooks/__tests__/use-browser-pure-p2p-account-upgrade.test.tsx index 5f33c079..4f977c44 100644 --- a/src/hooks/__tests__/use-browser-pure-p2p-account-upgrade.test.tsx +++ b/src/hooks/__tests__/use-browser-pure-p2p-account-upgrade.test.tsx @@ -9,15 +9,18 @@ const act = (React as { act?: (cb: () => void | Promise) => void | Promise const testState = vi.hoisted(() => ({ account: undefined as Record | undefined, accounts: [] as Record[] | undefined, + accountsState: 'initializing', createAccountMock: vi.fn().mockResolvedValue(undefined), + setActiveAccountMock: vi.fn().mockResolvedValue(undefined), setAccountMock: vi.fn().mockResolvedValue(undefined), })); vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ createAccount: () => testState.createAccountMock(), setAccount: (account: unknown) => testState.setAccountMock(account), + setActiveAccount: (accountName: string) => testState.setActiveAccountMock(accountName), useAccount: () => testState.account, - useAccounts: () => ({ accounts: testState.accounts }), + useAccounts: () => ({ accounts: testState.accounts, state: testState.accountsState }), })); let container: HTMLDivElement; @@ -48,7 +51,9 @@ describe('useBrowserPureP2PAccountUpgrade', () => { localStorage.clear(); testState.account = undefined; testState.accounts = []; + testState.accountsState = 'initializing'; testState.createAccountMock.mockReset().mockResolvedValue(undefined); + testState.setActiveAccountMock.mockReset().mockResolvedValue(undefined); testState.setAccountMock.mockReset().mockResolvedValue(undefined); reloadMock = vi.fn(); Object.defineProperty(window, 'location', { @@ -144,31 +149,56 @@ describe('useBrowserPureP2PAccountUpgrade', () => { expect(reloadMock).not.toHaveBeenCalled(); }); - it('recovers a missing browser account after the hooks store finishes initializing', async () => { + it('waits for the hooks store instead of creating duplicate accounts while initialization is running', async () => { vi.useFakeTimers(); window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = true; await renderHook(); await act(async () => { - await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(5000); }); + expect(testState.createAccountMock).not.toHaveBeenCalled(); - - window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = false; - await act(async () => { - await vi.advanceTimersByTimeAsync(1000); - await Promise.resolve(); - }); - - expect(testState.createAccountMock).toHaveBeenCalledOnce(); - expect(reloadMock).toHaveBeenCalledOnce(); + expect(testState.setActiveAccountMock).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); }); - it('treats temporarily missing accounts as empty while recovering', async () => { + it('recovers an empty account store without reloading', async () => { + window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = false; + + await renderHook(); + + expect(testState.createAccountMock).toHaveBeenCalledOnce(); + expect(testState.setActiveAccountMock).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it('selects the first existing account when no active account is selected', async () => { + testState.accountsState = 'succeeded'; + testState.account = undefined; + testState.accounts = [ + { + id: 'account-1', + name: 'Account 1', + }, + { + id: 'account-2', + name: 'Account 2', + }, + ]; + + await renderHook(); + + expect(testState.setActiveAccountMock).toHaveBeenCalledWith('Account 1'); + expect(testState.createAccountMock).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it('treats temporarily missing accounts as still initializing', async () => { vi.useFakeTimers(); testState.accounts = undefined; - window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = false; + window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = true; await renderHook(); @@ -177,7 +207,8 @@ describe('useBrowserPureP2PAccountUpgrade', () => { await Promise.resolve(); }); - expect(testState.createAccountMock).toHaveBeenCalledOnce(); - expect(reloadMock).toHaveBeenCalledOnce(); + expect(testState.createAccountMock).not.toHaveBeenCalled(); + expect(testState.setActiveAccountMock).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); }); }); diff --git a/src/hooks/use-browser-pure-p2p-account-upgrade.ts b/src/hooks/use-browser-pure-p2p-account-upgrade.ts index 61253849..b16243af 100644 --- a/src/hooks/use-browser-pure-p2p-account-upgrade.ts +++ b/src/hooks/use-browser-pure-p2p-account-upgrade.ts @@ -1,41 +1,45 @@ import { useEffect, useRef } from 'react'; -import { createAccount, setAccount, useAccount, useAccounts } from '@bitsocial/bitsocial-react-hooks'; +import { createAccount, setAccount, setActiveAccount, useAccount, useAccounts } from '@bitsocial/bitsocial-react-hooks'; import { getBrowserPureP2PAccountOptions, shouldUpgradeBrowserPureP2PAccount } from '../lib/p2p-runtime'; type AccountShape = Record & { id?: string; + name?: string; }; -const ACCOUNT_RECOVERY_CHECK_MS = 1000; - export const useBrowserPureP2PAccountUpgrade = () => { const account = useAccount() as AccountShape | undefined; - const { accounts = [] } = useAccounts(); - const recoveryStartedRef = useRef(false); + const { accounts = [], state: accountsState } = useAccounts(); + const activeAccountRecoveryNameRef = useRef(undefined); + const missingAccountRecoveryStartedRef = useRef(false); const upgradeAccountIdRef = useRef(undefined); + const firstAccountName = accounts[0]?.name; useEffect(() => { - if (account?.id || accounts.length > 0 || recoveryStartedRef.current) return; + const accountsStoreInitializing = window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING === true; - const intervalId = window.setInterval(() => { - if (window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING) return; + if (account?.id) return; - recoveryStartedRef.current = true; - window.clearInterval(intervalId); - void createAccount() - .then(() => { - window.location.reload(); - }) - .catch((error) => { - recoveryStartedRef.current = false; - console.error('Failed to recover missing browser account', error); - }); - }, ACCOUNT_RECOVERY_CHECK_MS); + if (accounts.length === 0) { + if (accountsStoreInitializing || missingAccountRecoveryStartedRef.current) return; - return () => { - window.clearInterval(intervalId); - }; - }, [account?.id, accounts.length]); + missingAccountRecoveryStartedRef.current = true; + void createAccount().catch((error) => { + missingAccountRecoveryStartedRef.current = false; + console.error('Failed to recover missing browser account', error); + }); + return; + } + + if (!firstAccountName) return; + if (activeAccountRecoveryNameRef.current === firstAccountName) return; + + activeAccountRecoveryNameRef.current = firstAccountName; + void setActiveAccount(firstAccountName).catch((error) => { + activeAccountRecoveryNameRef.current = undefined; + console.error('Failed to recover missing active browser account', error); + }); + }, [account?.id, accounts.length, accountsState, firstAccountName]); useEffect(() => { if (!account?.id || !shouldUpgradeBrowserPureP2PAccount(account)) return; diff --git a/src/lib/__tests__/p2p-browser-config.test.ts b/src/lib/__tests__/p2p-browser-config.test.ts index 59324f31..4d67b858 100644 --- a/src/lib/__tests__/p2p-browser-config.test.ts +++ b/src/lib/__tests__/p2p-browser-config.test.ts @@ -1,3 +1,4 @@ +import { DEFAULT_HTTP_ROUTER_URLS } from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/account-generator.js'; import { describe, expect, it } from 'vitest'; import { @@ -17,7 +18,7 @@ const createStorage = (values: Record = {}) => ({ }); describe('p2p-browser-config', () => { - const defaultHttpRouters = ['https://peers.plebpubsub.xyz', 'https://routing.lol', 'https://peers.pleb.bot']; + const defaultHttpRouters = DEFAULT_HTTP_ROUTER_URLS; it('configures browser PKC options for gateway mode by default', () => { const chainProviders = { @@ -37,6 +38,7 @@ describe('p2p-browser-config', () => { expect(targetWindow.defaultPkcOptions).toEqual({ chainProviders, ...getBrowserGatewayPkcOptions(), + httpRoutersOptions: defaultHttpRouters, }); }); @@ -58,6 +60,7 @@ describe('p2p-browser-config', () => { expect(targetWindow.defaultPkcOptions).toEqual({ chainProviders, ...getBrowserGatewayPkcOptions(), + httpRoutersOptions: defaultHttpRouters, }); }); diff --git a/src/lib/p2p-browser-config.ts b/src/lib/p2p-browser-config.ts index 618348df..295760c4 100644 --- a/src/lib/p2p-browser-config.ts +++ b/src/lib/p2p-browser-config.ts @@ -2,6 +2,15 @@ export const PURE_P2P_BROWSER_SETTING_KEY = '5chan:pure-p2p-browser-enabled'; export const BROWSER_PURE_P2P_DEFAULT_ENABLED = false; const BROWSER_PUBSUB_KUBO_RPC_CLIENTS_OPTIONS = ['https://pubsubprovider.xyz/api/v0', 'https://plebpubsub.xyz/api/v0', 'https://rannithepleb.com/api/v0']; +// Keep this aligned with bitsocial-react-hooks' DEFAULT_HTTP_ROUTER_URLS without relying on a package-internal runtime import before window.defaultPkcOptions is configured. +const DEFAULT_HTTP_ROUTER_URLS = [ + 'https://peers.pleb.bot', + 'https://routing.lol', + 'https://peers.forumindex.com', + 'https://peers.plebpubsub.xyz', + 'https://routerofbitsocial.xyz', + 'https://bsotracker.online', +]; export const P2P_BROWSER_PKC_OPTIONS = { libp2pJsClientsOptions: [{ key: 'libp2pjs' }], @@ -9,7 +18,7 @@ export const P2P_BROWSER_PKC_OPTIONS = { kuboRpcClientsOptions: undefined, pubsubHttpClientsOptions: undefined, pubsubKuboRpcClientsOptions: undefined as string[] | undefined, - httpRoutersOptions: ['https://peers.plebpubsub.xyz', 'https://routing.lol', 'https://peers.pleb.bot'], + httpRoutersOptions: DEFAULT_HTTP_ROUTER_URLS, }; const GATEWAY_BROWSER_PKC_OPTIONS = { @@ -18,7 +27,7 @@ const GATEWAY_BROWSER_PKC_OPTIONS = { libp2pJsClientsOptions: undefined, pubsubHttpClientsOptions: undefined, pubsubKuboRpcClientsOptions: BROWSER_PUBSUB_KUBO_RPC_CLIENTS_OPTIONS, - httpRoutersOptions: ['https://routing.lol', 'https://peers.pleb.bot', 'https://peers.plebpubsub.xyz', 'https://peers.forumindex.com'], + httpRoutersOptions: DEFAULT_HTTP_ROUTER_URLS, }; type P2PBrowserConfigWindow = {