diff --git a/__tests__/components/user/brain/UserPageBrainWrapper.test.tsx b/__tests__/components/user/brain/UserPageBrainWrapper.test.tsx index 7507a52b11..cbfa9e70b8 100644 --- a/__tests__/components/user/brain/UserPageBrainWrapper.test.tsx +++ b/__tests__/components/user/brain/UserPageBrainWrapper.test.tsx @@ -1,10 +1,9 @@ import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import UserPageBrainWrapper from "@/components/user/brain/UserPageBrainWrapper"; import { useIdentity } from "@/hooks/useIdentity"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; jest.mock("next/navigation", () => ({ - useRouter: jest.fn(), useParams: jest.fn(), })); jest.mock("@/components/auth/SeizeConnectContext", () => ({ @@ -12,15 +11,10 @@ jest.mock("@/components/auth/SeizeConnectContext", () => ({ })); jest.mock("@/hooks/useIdentity", () => ({ useIdentity: jest.fn() })); jest.mock("@/components/user/brain/UserPageDrops", () => (props: any) => ( -
+
{props.profile?.id ?? "none"}
)); -const routerPush = jest.fn(); -const useRouter = require("next/navigation").useRouter; const useParams = require("next/navigation").useParams; -(useRouter as jest.Mock).mockReturnValue({ - push: routerPush, -}); (useParams as jest.Mock).mockReturnValue({ user: "alice", }); @@ -28,9 +22,12 @@ const useParams = require("next/navigation").useParams; function renderWithContext(ctx: any) { (useSeizeConnectContext as jest.Mock).mockReturnValue({ address: ctx.address, + connectionState: ctx.connectionState ?? "disconnected", }); const AuthCtx = require("@/components/auth/Auth").AuthContext; - (useIdentity as jest.Mock).mockReturnValue({ profile: ctx.identity }); + (useIdentity as jest.Mock).mockReturnValue({ + profile: "hydratedIdentity" in ctx ? ctx.hydratedIdentity : ctx.identity, + }); return render( @@ -39,33 +36,68 @@ function renderWithContext(ctx: any) { } describe("UserPageBrainWrapper", () => { - beforeEach(() => { - routerPush.mockClear(); + it("renders a placeholder when brain stays unavailable", () => { + const { container } = renderWithContext({ + address: "0x1", + connectionState: "connected", + auth: { + connectedProfile: null, + fetchingProfile: false, + showWaves: false, + }, + identity: { id: "1" }, + }); + + expect(screen.queryByTestId("drops")).not.toBeInTheDocument(); + expect(container.querySelector(".tw-min-h-screen")).toBeInTheDocument(); }); - it("redirects to rep when waves disabled", () => { + + it("shows drops when waves enabled", () => { renderWithContext({ + address: "0x1", + connectionState: "connected", + auth: { + connectedProfile: null, + fetchingProfile: false, + showWaves: true, + }, + identity: { id: "1" }, + }); + + expect(screen.getByTestId("drops")).toBeInTheDocument(); + expect(screen.getByTestId("drops")).toHaveTextContent("1"); + }); + + it("keeps the placeholder while hydration is still pending", () => { + const { container } = renderWithContext({ address: undefined, + connectionState: "initializing", auth: { connectedProfile: null, - activeProfileProxy: null, + fetchingProfile: false, showWaves: false, }, identity: { id: "1" }, + hydratedIdentity: null, }); - expect(routerPush).toHaveBeenCalledWith("/alice"); + + expect(screen.queryByTestId("drops")).not.toBeInTheDocument(); + expect(container.querySelector(".tw-min-h-screen")).toBeInTheDocument(); }); - it("shows drops when waves enabled", () => { - const { getByTestId } = renderWithContext({ + it("falls back to the server profile when waves are available", () => { + renderWithContext({ address: "0x1", + connectionState: "connected", auth: { connectedProfile: null, - activeProfileProxy: null, + fetchingProfile: false, showWaves: true, }, - identity: { id: "1" }, + identity: { id: "server-profile" }, + hydratedIdentity: null, }); - expect(getByTestId("drops")).toBeInTheDocument(); - expect(routerPush).not.toHaveBeenCalled(); + + expect(screen.getByTestId("drops")).toHaveTextContent("server-profile"); }); }); diff --git a/__tests__/components/user/layout/UserPageTabs.test.tsx b/__tests__/components/user/layout/UserPageTabs.test.tsx index d2cc56bc4e..d1c4163a11 100644 --- a/__tests__/components/user/layout/UserPageTabs.test.tsx +++ b/__tests__/components/user/layout/UserPageTabs.test.tsx @@ -18,6 +18,10 @@ const useAuthMock = jest.fn(); jest.mock("@/components/auth/Auth", () => ({ useAuth: () => useAuthMock(), })); +const useSeizeConnectContextMock = jest.fn(); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: () => useSeizeConnectContextMock(), +})); const capacitorMock = jest.fn(); jest.mock("@/hooks/useCapacitor", () => ({ __esModule: true, @@ -25,7 +29,14 @@ jest.mock("@/hooks/useCapacitor", () => ({ })); jest.mock("@/components/user/layout/UserPageTab", () => ({ __esModule: true, - default: (p: any) =>
{p.tab.id}
, + default: (p: any) => ( +
+ {p.tab.id} +
+ ), })); jest.mock("@/components/cookies/CookieConsentContext", () => ({ useCookieConsent: jest.fn(), @@ -35,6 +46,15 @@ const { useCookieConsent, } = require("@/components/cookies/CookieConsentContext"); +const getTabIds = () => + screen.getAllByTestId("tab").map((tab) => tab.textContent); + +const getActiveTabIds = () => + screen + .getAllByTestId("tab") + .filter((tab) => tab.getAttribute("data-active") === "true") + .map((tab) => tab.textContent); + const renderTabs = ({ showWaves, isIos, @@ -42,6 +62,8 @@ const renderTabs = ({ connectedProfile = null, fetchingProfile = false, pathname = "/[user]", + address = undefined, + connectionState = "disconnected", }: { showWaves: boolean; isIos: boolean; @@ -49,6 +71,13 @@ const renderTabs = ({ connectedProfile?: any; fetchingProfile?: boolean; pathname?: string; + address?: string; + connectionState?: + | "initializing" + | "disconnected" + | "connecting" + | "connected" + | "error"; }) => { const router = { push: jest.fn(), replace: jest.fn() }; (useRouter as jest.Mock).mockReturnValue(router); @@ -67,6 +96,10 @@ const renderTabs = ({ connectedProfile, fetchingProfile, }); + useSeizeConnectContextMock.mockReturnValue({ + address, + connectionState, + }); return { router, ...render(), @@ -76,7 +109,7 @@ const renderTabs = ({ describe("UserPageTabs", () => { it("filters tabs based on context and platform", () => { renderTabs({ showWaves: false, isIos: false }); - const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); + const tabs = getTabIds(); expect(tabs).not.toContain(USER_PAGE_TAB_IDS.BRAIN); expect(tabs).not.toContain("stats"); expect(tabs).toContain(USER_PAGE_TAB_IDS.SUBSCRIPTIONS); @@ -84,7 +117,7 @@ describe("UserPageTabs", () => { it("hides subscriptions tab on iOS non-US", () => { renderTabs({ showWaves: true, isIos: true, country: "CA" }); - const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); + const tabs = getTabIds(); expect(tabs).not.toContain(USER_PAGE_TAB_IDS.SUBSCRIPTIONS); }); @@ -97,7 +130,7 @@ describe("UserPageTabs", () => { wallets: [], }, }); - const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); + const tabs = getTabIds(); expect(tabs).toContain(USER_PAGE_TAB_IDS.PROXY); }); @@ -110,7 +143,7 @@ describe("UserPageTabs", () => { wallets: [{ wallet: "testuser" }], }, }); - const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); + const tabs = getTabIds(); expect(tabs).toContain(USER_PAGE_TAB_IDS.PROXY); }); @@ -123,13 +156,13 @@ describe("UserPageTabs", () => { wallets: [{ wallet: "0xSomeOtherWallet" }], }, }); - const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); + const tabs = getTabIds(); expect(tabs).not.toContain(USER_PAGE_TAB_IDS.PROXY); }); it("hides proxy tab when not connected", () => { renderTabs({ showWaves: false, isIos: false }); - const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); + const tabs = getTabIds(); expect(tabs).not.toContain(USER_PAGE_TAB_IDS.PROXY); }); @@ -141,11 +174,121 @@ describe("UserPageTabs", () => { fetchingProfile: true, }); - const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); + const tabs = getTabIds(); expect(tabs).toContain(USER_PAGE_TAB_IDS.PROXY); expect(router.replace).not.toHaveBeenCalled(); }); + it("avoids redirecting away from brain while wallet state is initializing", () => { + const { router } = renderTabs({ + showWaves: false, + isIos: false, + pathname: "/testuser/brain", + connectionState: "initializing", + }); + + const tabs = getTabIds(); + const activeTabs = getActiveTabIds(); + expect(tabs).toContain(USER_PAGE_TAB_IDS.BRAIN); + expect(activeTabs).toEqual([USER_PAGE_TAB_IDS.BRAIN]); + expect(router.replace).not.toHaveBeenCalled(); + }); + + it("avoids redirecting away from brain while wallet state is connecting", () => { + const { router } = renderTabs({ + showWaves: false, + isIos: false, + pathname: "/testuser/brain", + connectionState: "connecting", + }); + + const tabs = getTabIds(); + const activeTabs = getActiveTabIds(); + expect(tabs).toContain(USER_PAGE_TAB_IDS.BRAIN); + expect(activeTabs).toEqual([USER_PAGE_TAB_IDS.BRAIN]); + expect(router.replace).not.toHaveBeenCalled(); + }); + + it("avoids redirecting away from brain while profile access is still loading", () => { + const { router } = renderTabs({ + showWaves: false, + isIos: false, + pathname: "/testuser/brain", + address: "0x1", + connectionState: "connected", + fetchingProfile: true, + }); + + const tabs = getTabIds(); + const activeTabs = getActiveTabIds(); + expect(tabs).toContain(USER_PAGE_TAB_IDS.BRAIN); + expect(activeTabs).toEqual([USER_PAGE_TAB_IDS.BRAIN]); + expect(router.replace).not.toHaveBeenCalled(); + }); + + it("redirects away from the brain tab after loading when waves stay unavailable", async () => { + const { rerender, router } = renderTabs({ + showWaves: false, + isIos: false, + pathname: "/testuser/brain", + address: "0x1", + connectionState: "connected", + fetchingProfile: true, + }); + + useAuthMock.mockReturnValue({ + showWaves: false, + connectedProfile: null, + fetchingProfile: false, + }); + useSeizeConnectContextMock.mockReturnValue({ + address: "0x1", + connectionState: "connected", + }); + + rerender(); + + await waitFor(() => { + expect(getTabIds()).not.toContain(USER_PAGE_TAB_IDS.BRAIN); + expect(getActiveTabIds()).toEqual([USER_PAGE_TAB_IDS.REP]); + expect(router.replace).toHaveBeenCalledWith("/testuser"); + }); + }); + + it("shows the brain tab and avoids redirect once waves become available", async () => { + const { rerender, router } = renderTabs({ + showWaves: false, + isIos: false, + pathname: "/testuser/brain", + address: "0x1", + connectionState: "connected", + fetchingProfile: true, + }); + + useAuthMock.mockReturnValue({ + showWaves: true, + connectedProfile: { + normalised_handle: "testuser", + wallets: [{ wallet: "0x1" }], + }, + fetchingProfile: false, + }); + useSeizeConnectContextMock.mockReturnValue({ + address: "0x1", + connectionState: "connected", + }); + + rerender(); + + await waitFor(() => { + const tabs = getTabIds(); + const activeTabs = getActiveTabIds(); + expect(tabs).toContain(USER_PAGE_TAB_IDS.BRAIN); + expect(activeTabs).toEqual([USER_PAGE_TAB_IDS.BRAIN]); + expect(router.replace).not.toHaveBeenCalled(); + }); + }); + it("redirects away from the proxy tab after loading when the profile is not owned", async () => { const { rerender, router } = renderTabs({ showWaves: false, diff --git a/components/user/brain/UserPageBrainWrapper.tsx b/components/user/brain/UserPageBrainWrapper.tsx index 8e724495ab..348e8a0644 100644 --- a/components/user/brain/UserPageBrainWrapper.tsx +++ b/components/user/brain/UserPageBrainWrapper.tsx @@ -1,11 +1,9 @@ - "use client"; import { AuthContext } from "@/components/auth/Auth"; -import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { useIdentity } from "@/hooks/useIdentity"; -import { useParams, useRouter } from "next/navigation"; -import { useContext, useEffect } from "react"; +import { useParams } from "next/navigation"; +import { useContext } from "react"; import UserPageDrops from "./UserPageDrops"; export default function UserPageBrainWrapper({ @@ -14,21 +12,9 @@ export default function UserPageBrainWrapper({ readonly profile: ApiIdentity; }) { const params = useParams(); - const router = useRouter(); - const user = (params?.["user"] as string)?.toLowerCase(); - - const { address } = useSeizeConnectContext(); - const { connectedProfile, activeProfileProxy, showWaves } = - useContext(AuthContext); + const user = (params["user"] as string).toLowerCase(); - useEffect(() => { - if (showWaves) { - return; - } - if (connectedProfile || !address) { - router.push(`/${user}`); - } - }, [connectedProfile, activeProfileProxy, address, showWaves]); + const { showWaves } = useContext(AuthContext); const { profile } = useIdentity({ handleOrWallet: user, @@ -39,5 +25,5 @@ export default function UserPageBrainWrapper({ return
; } - return ; + return ; } diff --git a/components/user/layout/UserPageTabs.tsx b/components/user/layout/UserPageTabs.tsx index 91d2fcfc20..90f7527e72 100644 --- a/components/user/layout/UserPageTabs.tsx +++ b/components/user/layout/UserPageTabs.tsx @@ -2,6 +2,7 @@ import { useAuth } from "@/components/auth/Auth"; import { useCookieConsent } from "@/components/cookies/CookieConsentContext"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { isOwnProfileRoute } from "@/helpers/ProfileHelpers"; import useCapacitor from "@/hooks/useCapacitor"; import { @@ -15,7 +16,14 @@ import { useRouter, useSearchParams, } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import UserPageTab from "./UserPageTab"; import { DEFAULT_USER_PAGE_TAB, @@ -25,8 +33,12 @@ import { type UserPageVisibilityContext, getUserPageTabByRoute, } from "./userTabs.config"; +import { shouldDelayUserPageBrainRedirect } from "./userPageBrainAccess"; const DEFAULT_TAB = DEFAULT_USER_PAGE_TAB; +const subscribeToClientRender = () => () => undefined; +const getClientRenderSnapshot = () => true; +const getServerRenderSnapshot = () => false; // Normalize consent country to uppercase code; empty or non-strings become null. const normalizeCountry = ( @@ -67,15 +79,16 @@ const resolveTabFromPath = (pathname: string): UserPageTabKey => { }; export default function UserPageTabs() { - const pathname = usePathname() ?? ""; + const pathname = usePathname(); const router = useRouter(); const params = useParams(); - const handleOrWallet = params?.["user"]?.toString() ?? ""; + const handleOrWallet = params["user"]?.toString() ?? ""; const searchParams = useSearchParams(); - const searchString = searchParams?.toString() ?? ""; + const searchString = searchParams.toString(); const capacitor = useCapacitor(); const { country } = useCookieConsent(); const { showWaves, connectedProfile, fetchingProfile } = useAuth(); + const { address, connectionState } = useSeizeConnectContext(); const isOwnProfile = useMemo(() => { return isOwnProfileRoute({ @@ -99,6 +112,11 @@ export default function UserPageTabs() { const contentContainerRef = useRef(null); const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); + const isClientHydrated = useSyncExternalStore( + subscribeToClientRender, + getClientRenderSnapshot, + getServerRenderSnapshot + ); const resolvedTabFromPath = useMemo( () => resolveTabFromPath(pathname), @@ -110,6 +128,17 @@ export default function UserPageTabs() { !connectedProfile && resolvedTabFromPath === USER_PAGE_TAB_IDS.PROXY; + const shouldSuppressBrainRedirect = + resolvedTabFromPath === USER_PAGE_TAB_IDS.BRAIN && + shouldDelayUserPageBrainRedirect({ + address, + connectedProfile, + connectionState, + fetchingProfile, + isClientHydrated, + }); + const preserveBrainTabWhileAccessLoads = shouldSuppressBrainRedirect; + const visibleTabs = useMemo( () => USER_PAGE_TABS.filter((tab) => { @@ -120,9 +149,20 @@ export default function UserPageTabs() { return true; } + if ( + preserveBrainTabWhileAccessLoads && + tab.id === USER_PAGE_TAB_IDS.BRAIN + ) { + return true; + } + return tab.isVisible ? tab.isVisible(visibilityContext) : true; }), - [preserveProxyTabWhileOwnershipLoads, visibilityContext] + [ + preserveBrainTabWhileAccessLoads, + preserveProxyTabWhileOwnershipLoads, + visibilityContext, + ] ); const resolvedTabIsVisible = useMemo( @@ -143,11 +183,15 @@ export default function UserPageTabs() { // Redirect to the first visible tab whenever the resolved tab becomes // hidden because the visibility context changed (country, feature flags, - // etc.). The early returns combined with `resolvedTabIsVisible` and the - // pathname comparison ensure we only navigate when needed, preventing - // redirect loops even if the context flaps quickly. + // etc.). When loading `/brain` directly, delay the redirect until the client + // has mounted and wallet/profile restoration has settled. useEffect(() => { - if (!visibleTabs.length || resolvedTabIsVisible || !handleOrWallet) { + if ( + !visibleTabs.length || + resolvedTabIsVisible || + !handleOrWallet || + shouldSuppressBrainRedirect + ) { return; } @@ -174,6 +218,7 @@ export default function UserPageTabs() { resolvedTabIsVisible, router, searchString, + shouldSuppressBrainRedirect, visibleTabs, ]); diff --git a/components/user/layout/userPageBrainAccess.ts b/components/user/layout/userPageBrainAccess.ts new file mode 100644 index 0000000000..d0f4b0be06 --- /dev/null +++ b/components/user/layout/userPageBrainAccess.ts @@ -0,0 +1,30 @@ +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; + +export function shouldDelayUserPageBrainRedirect({ + address, + connectedProfile, + connectionState, + fetchingProfile, + isClientHydrated, +}: { + readonly address: string | undefined; + readonly connectedProfile: ApiIdentity | null; + readonly connectionState: + | "initializing" + | "disconnected" + | "connecting" + | "connected" + | "error"; + readonly fetchingProfile: boolean; + readonly isClientHydrated: boolean; +}): boolean { + if (!isClientHydrated) { + return true; + } + + const isWalletConnectingOrInitializing = + connectionState === "initializing" || connectionState === "connecting"; + const isProfileHydrating = !!address && fetchingProfile && !connectedProfile; + + return isWalletConnectingOrInitializing || isProfileHydrating; +}