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 ? (