diff --git a/web/src/app/community/layout.tsx b/web/src/app/community/layout.tsx index ce7de6c75..e2016e322 100644 --- a/web/src/app/community/layout.tsx +++ b/web/src/app/community/layout.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; -import { headers } from "next/headers"; +import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import type { Metadata } from "next"; +import { FIRMWARE_NOTICE_COOKIE, firmwareNoticeDismissed } from "@/lib/community/firmwareNotice"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { communityEnabled, communityHomeUrl } from "@/lib/community/server/db"; @@ -74,6 +75,11 @@ export default async function CommunityLayout({ children }: { children: React.Re : 0; const username = (session?.user as { username?: string } | undefined)?.username ?? null; + // Read here so the firmware notice is in the server's HTML exactly when it + // will stay on screen (FirmwareNotice.tsx). + const noticeDismissed = firmwareNoticeDismissed( + (await cookies()).get(FIRMWARE_NOTICE_COOKIE)?.value, + ); return (
@@ -81,7 +87,7 @@ export default async function CommunityLayout({ children }: { children: React.Re {/* Above the header on purpose. A board owner needs to see this once; putting it below the nav would let it sit under the fold on the page they actually came for. */} - +
diff --git a/web/src/components/community/FirmwareNotice.test.tsx b/web/src/components/community/FirmwareNotice.test.tsx new file mode 100644 index 000000000..63ba85f84 --- /dev/null +++ b/web/src/components/community/FirmwareNotice.test.tsx @@ -0,0 +1,103 @@ +// The server drew no firmware notice, because only the browser knew whether +// it had been dismissed. For everyone who had not, the banner came in at +// hydration and pushed the page down, 119px on a phone (CLS 0.141 at 390px, +// 26 October). A dismissal now goes into a cookie as well, and the community +// layout reads it, so the banner is in the server's HTML when it will stay. + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import CommunityLayout from "@/app/community/layout"; +import { FIRMWARE_NOTICE_COOKIE as KEY, FIRMWARE_NOTICE_VERSION as LATEST } from "@/lib/community/firmwareNotice"; +import FirmwareNotice from "./FirmwareNotice"; + +const jar = vi.hoisted(() => new Map()); + +vi.mock("next/headers", () => ({ + headers: async () => new Headers({ host: "localhost:3100" }), + cookies: async () => ({ + get: (name: string) => (jar.has(name) ? { name, value: jar.get(name) } : undefined), + }), +})); +vi.mock("@/lib/community/server/db", () => ({ + communityEnabled: () => true, + communityHomeUrl: () => "http://localhost:3100", +})); +vi.mock("@/lib/community/server/auth", () => ({ + getAuth: () => ({ api: { getSession: async () => null } }), +})); +vi.mock("@/lib/community/server/admin", () => ({ isAdminSession: () => false })); +vi.mock("@/lib/community/server/queries", () => ({ + countOpenReports: async () => 0, + countUnreadNotifications: async () => 0, +})); +// The rest of the header has tests of its own. +vi.mock("@/components/community/AuthStatus", () => ({ default: () => null })); +vi.mock("@/components/community/CommunityNav", () => ({ default: () => null })); +vi.mock("@/components/community/DeckDock", () => ({ default: () => null })); +vi.mock("@/components/community/DeckPanel", () => ({ default: () => null })); +vi.mock("@/components/community/MobileTabBar", () => ({ default: () => null })); + +const BANNER = "Pick an edition"; + +async function serverHtml(): Promise { + return renderToString(await CommunityLayout({ children:

wall

})); +} + +beforeEach(() => { + // The cookie is scoped to /community, so document.cookie only shows it on + // a community page. + window.history.replaceState(null, "", "/community/patterns"); +}); + +afterEach(() => { + jar.clear(); + document.cookie = `${KEY}=; Path=/community; Max-Age=0`; + window.localStorage.clear(); +}); + +describe("the community layout's HTML", () => { + it("has the notice when there is no cookie", async () => { + const html = await serverHtml(); + expect(html).toContain(BANNER); + expect(html).toContain("wall"); + }); + + it("leaves it out when the cookie dismisses this release", async () => { + jar.set(KEY, LATEST); + expect(await serverHtml()).not.toContain(BANNER); + }); + + it("has it again for a cookie an earlier release left", async () => { + jar.set(KEY, "v3.10.4"); + expect(await serverHtml()).toContain(BANNER); + }); +}); + +describe("dismissing", () => { + it("writes the cookie the layout reads, and localStorage", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Dismiss until the next release" })); + + expect(screen.queryByText(BANNER)).toBeNull(); + expect(document.cookie.split("; ")).toContain(`${KEY}=${LATEST}`); + expect(window.localStorage.getItem(KEY)).toBe(LATEST); + }); + + it("from before the cookie still counts, and the cookie is written for next time", async () => { + // Dismissed in localStorage alone: the server drew the banner, and + // hydration takes it away. + window.localStorage.setItem(KEY, LATEST); + const container = document.createElement("div"); + container.innerHTML = renderToString(); + document.body.appendChild(container); + expect(container.textContent).toContain(BANNER); + + await act(async () => { + render(, { container, hydrate: true }); + }); + + expect(container.textContent).not.toContain(BANNER); + expect(document.cookie.split("; ")).toContain(`${KEY}=${LATEST}`); + }); +}); diff --git a/web/src/components/community/FirmwareNotice.tsx b/web/src/components/community/FirmwareNotice.tsx index 28af69882..45485af21 100644 --- a/web/src/components/community/FirmwareNotice.tsx +++ b/web/src/components/community/FirmwareNotice.tsx @@ -1,8 +1,12 @@ "use client"; import Link from "next/link"; -import { useSyncExternalStore } from "react"; -import manifest from "../../../public/flash/manifest.json"; +import { useEffect, useSyncExternalStore } from "react"; +import { + FIRMWARE_NOTICE_COOKIE as KEY, + FIRMWARE_NOTICE_VERSION as LATEST, + firmwareNoticeDismissed, +} from "@/lib/community/firmwareNotice"; import styles from "./Community.module.css"; // "There is a new firmware" — said here, because there is nowhere else to say @@ -22,39 +26,61 @@ import styles from "./Community.module.css"; // Dismissal is per version, not forever. Dismiss this and it stays gone until // there is a different release to mention — it cannot know whether you updated // (see above), so the honest fallback is to ask once per version and drop it. +// +// The server draws the banner only for someone who will keep it. Until 26 +// October only the browser knew about a dismissal, so the server drew nothing +// and the banner arrived at hydration, pushing the whole page down: 119px on +// a phone, CLS 0.141 at 390px. A dismissal now goes into a cookie as well, and +// the layout reads it and passes it in. + +function dismissedHere(): boolean { + if (document.cookie.split("; ").includes(`${KEY}=${LATEST}`)) return true; + try { + return firmwareNoticeDismissed(window.localStorage.getItem(KEY)); + } catch { + return false; /* private mode */ + } +} -const LATEST = manifest.version; -const KEY = "pf-firmware-notice"; +/** A year, and only on the community's pages: the notice is drawn nowhere + * else, so no other request needs to carry the cookie. */ +function remember() { + const secure = window.location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `${KEY}=${LATEST}; Path=/community; Max-Age=31536000; SameSite=Lax${secure}`; + try { + window.localStorage.setItem(KEY, LATEST); + } catch { + /* private mode: the cookie still holds it */ + } +} -/** Read once per render from localStorage, with a server snapshot of "not - * dismissed" so the markup matches and the banner never flashes in late. */ -function useDismissed(): boolean { +/** Read once per render from the cookie and localStorage. The server snapshot + * is the cookie as the layout read it, so the markup matches. */ +function useDismissed(serverDismissed: boolean): boolean { return useSyncExternalStore( (onChange) => { window.addEventListener("storage", onChange); return () => window.removeEventListener("storage", onChange); }, - () => { - try { - return window.localStorage.getItem(KEY) === LATEST; - } catch { - return false; /* private mode */ - } - }, - () => true, // server: assume dismissed, so nothing appears before hydration + dismissedHere, + () => serverDismissed, ); } -export default function FirmwareNotice() { - const dismissed = useDismissed(); +export default function FirmwareNotice({ serverDismissed }: { serverDismissed: boolean }) { + const dismissed = useDismissed(serverDismissed); + + // Dismissed before the cookie existed, in localStorage alone: the server + // drew the banner and it left at hydration. Writing the cookie now keeps + // that to this one page. + useEffect(() => { + if (dismissed && !serverDismissed) remember(); + }, [dismissed, serverDismissed]); + if (dismissed) return null; const dismiss = () => { - try { - window.localStorage.setItem(KEY, LATEST); - } catch { - /* private mode — it will ask again, which is the lesser failure */ - } + remember(); window.dispatchEvent(new Event("storage")); }; diff --git a/web/src/lib/community/firmwareNotice.ts b/web/src/lib/community/firmwareNotice.ts new file mode 100644 index 000000000..261fea5bd --- /dev/null +++ b/web/src/lib/community/firmwareNotice.ts @@ -0,0 +1,19 @@ +import manifest from "../../../public/flash/manifest.json"; + +// The firmware notice's dismissal, in a cookie the server can read. Kept out +// of FirmwareNotice.tsx because that file is a client component, and the +// community layout reads these on the server: a server component importing a +// client module gets references, not these strings. + +/** The release the notice is about, from the flasher manifest. */ +export const FIRMWARE_NOTICE_VERSION = manifest.version; + +/** Holds the version that was dismissed. The same name is the localStorage + * key, where dismissals were kept before the server needed to see them. */ +export const FIRMWARE_NOTICE_COOKIE = "pf-firmware-notice"; + +/** True when the cookie (or stored value) dismisses this release. A value + * left by an earlier release does not: the notice asks once per version. */ +export function firmwareNoticeDismissed(value: string | null | undefined): boolean { + return value === FIRMWARE_NOTICE_VERSION; +}