commit e052cbe4a21d81145844b19b7f161f25b555e7b0
parent db57de0ac47061b0a73b83d1295224717b745a80
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 29 Sep 2026 23:02:18 -0400
editor(e2e): site-scope — the picker's first paint, a ?site= link's scope, and the localStorage migration
The three existing assertions that the URL gained `?site=` now assert it did
not (the choice is a cookie; the editor no longer seeds the param), and two
comments say the cookie where they said localStorage. Every other case is
unchanged.
New cases:
- the stored site is the select's value read ONCE after domcontentloaded on
/, /channels, /jobs, /settings and /sites/alpha, and on client-side
navigations through the sidebar; an init script records every value the
select ever had (per DOM mutation and per animation frame) and "__all__" is
never among them; visiting /sites/beta records beta for the next page; no
hydration warning. Playwright's retrying toHaveValue cannot see a one-paint
flash, which the spec says;
- a `?site=` link scopes its own page and is not stored; choosing on it drops
the param and stores the choice;
- a localStorage choice with no cookie moves to the cookie once and the key is
removed; with a cookie, a leftover key is removed and never read.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 208 insertions(+), 6 deletions(-)
diff --git a/editor/e2e/site-scope.spec.ts b/editor/e2e/site-scope.spec.ts
@@ -1,4 +1,6 @@
-import { test, expect } from "@playwright/test";
+import { test, expect, type Page } from "@playwright/test";
+import { activeSiteCookieName } from "../app/lib/activeSite";
+import { baseUrl } from "./baseUrl";
import { readJson, resetData, writeSite } from "./helpers";
type SiteFile = { channels: { slug: string }[] };
@@ -31,14 +33,16 @@ test("selecting a site scopes the channels list and persists", async ({
await page.goto("/channels");
await page.getByLabel("Active site").selectOption("alpha");
- await expect(page).toHaveURL(/site=alpha/);
+ // The choice is a cookie (release 15 SS): the URL is left alone, where it
+ // used to gain ?site=alpha.
+ await expect(page).toHaveURL(/\/channels$/);
await expect(page.getByRole("link", { name: "slow-a" })).toBeVisible();
await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0);
- // localStorage persists the choice: revisiting with no param re-applies it.
+ // The cookie persists the choice: revisiting with no param re-applies it.
await page.goto("/channels");
await expect(page.getByLabel("Active site")).toHaveValue("alpha");
- await expect(page).toHaveURL(/site=alpha/);
+ await expect(page).toHaveURL(/\/channels$/);
await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0);
// Switching to beta flips the scope.
@@ -70,10 +74,11 @@ test("charts is a site's tab, and the picker follows the path", async ({
await expect(page.getByText(/author the default dashboard/i)).toContainText(
"beta",
);
- // The path wrote localStorage, so the scoped pages follow.
+ // The path wrote the cookie, so the scoped pages follow — with no ?site=
+ // seeded onto the URL any more (release 15 SS).
await page.goto("/channels");
await expect(page.getByLabel("Active site")).toHaveValue("beta");
- await expect(page).toHaveURL(/site=beta/);
+ await expect(page).toHaveURL(/\/channels$/);
// "All sites" is the family page.
await page.goto("/sites/beta/charts");
await page.getByLabel("Active site").selectOption("__all__");
@@ -138,3 +143,200 @@ test("creating a channel under a site adds it to that site's membership", async
await page.goto("/channels?site=alpha");
await expect(page.getByRole("link", { name: "gamma" })).toBeVisible();
});
+
+// ── No wrong paint (release 15 slice SS) ─────────────────────────────────────
+//
+// The picker used to render "All sites" from the URL on every navigation and
+// then snap to the stored site once an effect had read localStorage and
+// rewritten the URL. Playwright's auto-retrying `toHaveValue` CANNOT see that:
+// it polls until the value is right and passes, flash or no flash. So these
+// cases read the value ONCE, with no retry, right after `domcontentloaded`, and
+// a script installed before any page script records every value the select
+// ever had — at every DOM mutation and every animation frame, i.e. everything
+// that could have been painted — and "__all__" must never be among them.
+
+const PICKER = 'select[aria-label="Active site"]';
+// The cookie this test server's requests use (its name carries the port).
+const COOKIE = activeSiteCookieName(new URL(baseUrl).host);
+// How long a page is watched after it hydrates. There is nothing to wait FOR:
+// the assertion is that nothing happens, and the old snap landed within one
+// server round trip of hydration.
+const SETTLE_MS = 750;
+
+async function recordPickerValues(page: Page) {
+ await page.addInitScript((selector) => {
+ const seen: string[] = [];
+ (window as unknown as { __pickerValues: string[] }).__pickerValues = seen;
+ const read = () => {
+ const el = document.querySelector<HTMLSelectElement>(selector);
+ if (el && seen[seen.length - 1] !== el.value) seen.push(el.value);
+ };
+ new MutationObserver(read).observe(document, {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ });
+ const frame = () => {
+ read();
+ requestAnimationFrame(frame);
+ };
+ requestAnimationFrame(frame);
+ }, PICKER);
+}
+
+function pickerValues(page: Page): Promise<string[]> {
+ return page.evaluate(
+ () =>
+ (window as unknown as { __pickerValues?: string[] }).__pickerValues ?? [],
+ );
+}
+
+// Hydrated = React has attached its props to the select. It is the one React
+// internal this spec peeks at; there is no public signal. Effects follow it.
+async function hydrated(page: Page) {
+ await page.waitForFunction((selector) => {
+ const el = document.querySelector(selector);
+ return !!el && Object.keys(el).some((k) => k.startsWith("__reactProps"));
+ }, PICKER);
+}
+
+// A hydration mismatch is logged by React in development; fail on any.
+function watchHydration(page: Page): string[] {
+ const seen: string[] = [];
+ page.on("console", (msg) => {
+ if (/hydrat/i.test(msg.text())) seen.push(msg.text());
+ });
+ page.on("pageerror", (err) => {
+ if (/hydrat/i.test(err.message)) seen.push(err.message);
+ });
+ return seen;
+}
+
+async function storedCookie(page: Page): Promise<string | undefined> {
+ const cookies = await page.context().cookies();
+ return cookies.find((c) => c.name === COOKIE)?.value;
+}
+
+// A full load, read at its first paint.
+async function firstPaint(page: Page, path: string): Promise<string> {
+ await page.goto(path, { waitUntil: "commit" });
+ await page.waitForLoadState("domcontentloaded");
+ return page.locator(PICKER).inputValue();
+}
+
+test("a stored site is the picker's first paint on every page, never All sites", async ({
+ page,
+}) => {
+ test.setTimeout(90_000);
+ await twoSites();
+ const hydration = watchHydration(page);
+ await recordPickerValues(page);
+ const picker = page.getByLabel("Active site");
+
+ // Store alpha the way a person does, and wait for it to land: the re-render
+ // that drops slow-b is the one the cookie write triggers.
+ await page.goto("/channels");
+ await picker.selectOption("alpha");
+ await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0);
+ expect(await storedCookie(page)).toBe("alpha");
+
+ // Full loads: the server renders the stored site, and nothing moves it.
+ for (const path of ["/", "/channels", "/jobs", "/settings", "/sites/alpha", "/"]) {
+ expect(await firstPaint(page, path), `${path}: first paint`).toBe("alpha");
+ await hydrated(page);
+ await page.waitForTimeout(SETTLE_MS);
+ expect(await pickerValues(page), `${path}: every value`).toEqual(["alpha"]);
+ }
+
+ // Client-side navigations: the layout persists, the picker re-renders.
+ const nav = page.locator("aside nav");
+ for (const [name, url] of [
+ ["Channels", /\/channels$/],
+ [/^Jobs/, /\/jobs$/],
+ ["Settings", /\/settings$/],
+ ["Sites", /\/sites$/],
+ ["Dashboard", /\/$/],
+ ] as const) {
+ await nav.getByRole("link", { name, exact: typeof name === "string" }).click();
+ await expect(page).toHaveURL(url);
+ expect(await picker.inputValue(), `${String(name)}: first render`).toBe("alpha");
+ }
+ await page.waitForTimeout(SETTLE_MS);
+ expect(await pickerValues(page), "every value across the navigations").toEqual([
+ "alpha",
+ ]);
+
+ // Visiting another site's page records it; Dashboard then paints it at once.
+ expect(await firstPaint(page, "/sites/beta"), "/sites/beta: first paint").toBe(
+ "beta",
+ );
+ await expect.poll(() => storedCookie(page), { timeout: 15_000 }).toBe("beta");
+ expect(await firstPaint(page, "/"), "/ after /sites/beta").toBe("beta");
+ await hydrated(page);
+ await page.waitForTimeout(SETTLE_MS);
+ expect(await pickerValues(page)).toEqual(["beta"]);
+
+ expect(hydration, "hydration warnings").toEqual([]);
+});
+
+test("a ?site= link scopes its own page and is not stored; a choice there drops it", async ({
+ page,
+}) => {
+ test.setTimeout(60_000);
+ await twoSites();
+ const picker = page.getByLabel("Active site");
+ await page.goto("/channels");
+ await picker.selectOption("alpha");
+ await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0);
+
+ // The link's scope, on the first paint, for this page only.
+ expect(await firstPaint(page, "/channels?site=beta")).toBe("beta");
+ await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "slow-a" })).toHaveCount(0);
+ expect(await storedCookie(page)).toBe("alpha");
+
+ // Not written: the next plain visit is the stored site's.
+ expect(await firstPaint(page, "/channels")).toBe("alpha");
+ await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0);
+
+ // Choosing on a link's page replaces the link's scope: stored, and the param
+ // goes, so the page and the picker agree.
+ await page.goto("/channels?site=beta");
+ await hydrated(page);
+ await picker.selectOption("__all__");
+ await expect(page).toHaveURL(/\/channels$/);
+ await expect(picker).toHaveValue("__all__");
+ await expect(page.getByRole("link", { name: "slow-a" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible();
+ expect(await storedCookie(page)).toBe("__all__");
+ expect(await firstPaint(page, "/channels")).toBe("__all__");
+});
+
+test("a choice the old picker kept in localStorage moves to the cookie once", async ({
+ page,
+}) => {
+ test.setTimeout(60_000);
+ await twoSites();
+ const picker = page.getByLabel("Active site");
+ const legacy = () => page.evaluate(() => localStorage.getItem("activeSite"));
+
+ // Seed the old key on the editor's origin from a route that mounts no app.
+ await page.goto("/api/pulse");
+ await page.evaluate(() => localStorage.setItem("activeSite", "beta"));
+
+ // The one visit that may flash: the server had no cookie to render from.
+ await page.goto("/channels");
+ await expect(picker).toHaveValue("beta");
+ await expect(page.getByRole("link", { name: "slow-a" })).toHaveCount(0);
+ await expect.poll(() => storedCookie(page), { timeout: 15_000 }).toBe("beta");
+ await expect.poll(legacy, { timeout: 15_000 }).toBeNull();
+
+ // From then on it is the first paint.
+ expect(await firstPaint(page, "/")).toBe("beta");
+
+ // With a cookie, a leftover key is removed and never read.
+ await page.evaluate(() => localStorage.setItem("activeSite", "alpha"));
+ expect(await firstPaint(page, "/channels")).toBe("beta");
+ await expect.poll(legacy, { timeout: 15_000 }).toBeNull();
+ expect(await storedCookie(page)).toBe("beta");
+});