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;
+}