diff --git a/web/src/app/community/layout.tsx b/web/src/app/community/layout.tsx index 6592d515d..ce7de6c75 100644 --- a/web/src/app/community/layout.tsx +++ b/web/src/app/community/layout.tsx @@ -124,7 +124,19 @@ export default async function CommunityLayout({ children }: { children: React.Re Alerts{unread > 0 ? ` ${unread}` : ""} )} - + {/* Three fields, not the whole user: what crosses to the browser + here lands in the page for anyone who saves it. */} + diff --git a/web/src/components/community/AuthStatus.test.tsx b/web/src/components/community/AuthStatus.test.tsx new file mode 100644 index 000000000..cf78609ea --- /dev/null +++ b/web/src/components/community/AuthStatus.test.tsx @@ -0,0 +1,69 @@ +// The header asks the browser for its session after the page arrives. While +// it waited it drew "Sign in", so a signed-in reader's header opened on two +// lines on a phone and pulled back to one 0.6–0.9 s later, on every page. The +// layout already read the session on the server; the widget draws that user +// until the browser has its own answer. + +import { render, screen } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import { afterEach, expect, it, vi } from "vitest"; +import AuthStatus, { type HeaderUser } from "./AuthStatus"; + +type SessionUser = HeaderUser & { id: string }; + +const auth = vi.hoisted(() => ({ + session: null as { user: SessionUser } | null, + pending: true, +})); + +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); +vi.mock("@/lib/community/auth-client", () => ({ + authClient: { useSession: () => ({ data: auth.session, isPending: auth.pending }) }, + dummyEmailFor: (name: string) => `${name}@example.invalid`, +})); + +afterEach(() => { + auth.session = null; + auth.pending = true; +}); + +const ADA: HeaderUser = { name: "ada", username: "ada", displayUsername: "Ada" }; + +it("draws the server's user while the browser is still asking", () => { + render(); + expect(screen.getByRole("link", { name: "@Ada" }).getAttribute("href")).toBe("/community/u/ada"); + expect(screen.queryByRole("button", { name: "Sign in" })).toBeNull(); +}); + +it("puts the handle in the server's markup, not Sign in", () => { + const html = renderToString(); + expect(html).toContain("@Ada"); + expect(html).not.toContain("Sign in"); +}); + +it("offers Sign in while asking when the server saw nobody", () => { + render(); + expect(screen.getByRole("button", { name: "Sign in" })).toBeTruthy(); +}); + +it("takes the browser's answer once it has one", () => { + auth.pending = false; + auth.session = { user: { id: "u1", name: "ada", username: "ada", displayUsername: "ADA" } }; + render(); + expect(screen.getByRole("link", { name: "@ADA" })).toBeTruthy(); +}); + +it("stays signed out after the answer, through a later refetch", () => { + // Signed out in another tab: the server's user is stale. Better Auth marks a + // refetch of a null session pending, and the header must not bring the old + // handle back for it. + const view = render(); + auth.pending = false; + view.rerender(); + expect(screen.getByRole("button", { name: "Sign in" })).toBeTruthy(); + + auth.pending = true; + view.rerender(); + expect(screen.getByRole("button", { name: "Sign in" })).toBeTruthy(); + expect(screen.queryByRole("link", { name: "@Ada" })).toBeNull(); +}); diff --git a/web/src/components/community/AuthStatus.tsx b/web/src/components/community/AuthStatus.tsx index 8cddf7f5c..873487bac 100644 --- a/web/src/components/community/AuthStatus.tsx +++ b/web/src/components/community/AuthStatus.tsx @@ -10,23 +10,35 @@ import styles from "./Community.module.css"; // Header widget: signed-out → "Sign in" button; signed-in → username link // (own pattern list) + sign out. +// +// The browser asks for its own session after the page arrives, 0.6–0.9 s on +// a phone. Until it has the answer the widget draws the user the layout read +// on the server. Drawing "Sign in" while it waited put a signed-in reader's +// header on two lines and then pulled it back to one, on every page. -export default function AuthStatus() { +export type HeaderUser = { + name: string; + username?: string | null; + displayUsername?: string | null; +}; + +export default function AuthStatus({ serverUser = null }: { serverUser?: HeaderUser | null }) { const router = useRouter(); - const { data: session } = authClient.useSession(); + const { data: session, isPending } = authClient.useSession(); const [authOpen, setAuthOpen] = useState(false); + // Only until the first answer. Better Auth marks every later refetch of a + // signed-out session pending as well, and by then the server's user may be + // the one who just signed out in another tab. + const [answered, setAnswered] = useState(!isPending); + if (!isPending && !answered) setAnswered(true); - const username = - (session?.user as { username?: string; displayUsername?: string } | undefined)?.username ?? null; - const displayName = - (session?.user as { displayUsername?: string } | undefined)?.displayUsername ?? - username ?? - session?.user.name ?? - null; + const user: HeaderUser | null = answered ? (session?.user ?? null) : serverUser; + const username = user?.username ?? null; + const displayName = user?.displayUsername ?? username ?? user?.name ?? null; return ( <> - {session ? ( + {user ? (
@{displayName}