Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion web/src/app/community/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,19 @@ export default async function CommunityLayout({ children }: { children: React.Re
Alerts{unread > 0 ? ` ${unread}` : ""}
</Link>
)}
<AuthStatus />
{/* Three fields, not the whole user: what crosses to the browser
here lands in the page for anyone who saves it. */}
<AuthStatus
serverUser={
session
? {
name: session.user.name,
username: session.user.username,
displayUsername: session.user.displayUsername,
}
: null
}
/>
</nav>
</header>

Expand Down
69 changes: 69 additions & 0 deletions web/src/components/community/AuthStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AuthStatus serverUser={ADA} />);
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(<AuthStatus serverUser={ADA} />);
expect(html).toContain("@<!-- -->Ada");
expect(html).not.toContain("Sign in");
});

it("offers Sign in while asking when the server saw nobody", () => {
render(<AuthStatus serverUser={null} />);
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(<AuthStatus serverUser={ADA} />);
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(<AuthStatus serverUser={ADA} />);
auth.pending = false;
view.rerender(<AuthStatus serverUser={ADA} />);
expect(screen.getByRole("button", { name: "Sign in" })).toBeTruthy();

auth.pending = true;
view.rerender(<AuthStatus serverUser={ADA} />);
expect(screen.getByRole("button", { name: "Sign in" })).toBeTruthy();
expect(screen.queryByRole("link", { name: "@Ada" })).toBeNull();
});
32 changes: 22 additions & 10 deletions web/src/components/community/AuthStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<div className={styles.headerNav}>
<Link href={`/community/u/${username ?? ""}`} className={styles.userChip}>
@{displayName}
Expand Down
Loading