Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 2a3042656300eda15f8fdd46671460bc343710c9
parent e6bb04227e530605b1d80ef21433534f8e2ca4d4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 01:13:42 -0400

Merge r15/site-scope (release 15 slice SS) — the editor's active site is a cookie, read once by the root layout and supplied through a provider: the picker shows the stored site on its first paint and never rewrites the URL, Dashboard and Channels open in it at once, a ?site= link wins for that request, the editor's own navigation no longer appends ?site=, a pick in one tab reaches the others, the new-channel form starts on the active site; one-time migration from localStorage; reviewed SHIP

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/components/ChannelForm.tsx | 3++-
Meditor/app/channels/components/ChannelFormClient.tsx | 33++++++++++++++++-----------------
Meditor/app/channels/components/ChannelVolumeBar.tsx | 12++++++------
Meditor/app/channels/components/SiteMembershipsSection.tsx | 51++++++++++++++++++++++++++-------------------------
Meditor/app/channels/page.tsx | 5+++--
Aeditor/app/components/SiteScopeProvider.tsx | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/components/SiteScopeSelect.tsx | 119+++++++++++++++++++++++++++++++++++++------------------------------------------
Meditor/app/layout.tsx | 19+++++++++++++++++++
Meditor/app/lib/activeSite.test.ts | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/lib/activeSite.ts | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Aeditor/app/lib/activeSiteActions.ts | 31+++++++++++++++++++++++++++++++
Aeditor/app/lib/activeSiteServer.ts | 35+++++++++++++++++++++++++++++++++++
Meditor/app/page.tsx | 5+++--
Meditor/e2e/site-scope.spec.ts | 336+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Meditor/next.config.ts | 3++-
Mplans/FACTS.md | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mplans/release-15.md | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
18 files changed, 1242 insertions(+), 141 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -14,6 +14,7 @@ - **Two grounds, Light and Dark, and no accent picker in the header.** The editor's header keeps its theme toggle, which cycles System, Light and Dark; the theme menu (Base and Accent) is gone, and the editor wears its own accent, Signal. A stored choice of the retired third ground loads as Light and is rewritten once; a stored accent is not read and is left in storage. A site's accent is still set in its form; the form's hint no longer says a reader can pick another. - **The hub URL hints say what the setting does now.** Settings' **Family hub URL** and a site's **Hub URL** no longer promise a Hub link in the header (it was removed): the value is published as `hubUrl` in each site's `/site.json` and `/corpus.json`, so the hub can tell its member sites. `SETTINGS.md` and `SITE.md` say the same. - **A site can be left off the homepage and the hub.** A site's settings have a new checkbox, **List on the Archilyzer homepage and hub**, on by default (`listed` in `site.json`; only `false` is written). Turned off, the site still builds and deploys at its own URL as before, but the homepage has no card, chart series, `/stats` entry or recent item for it; the hub does not list it as a member, search it, or name it in its `corpus.json` and `llms.txt`; no other site's footer links it; and `channel-sites.json` and the homepage's `stats/` leave it out. A channel only unlisted sites carry is in none of the published totals, the homepage's headline numbers included; a channel a listed site also carries is counted under the listed site. The editor's own pages still show every site. It takes effect at the next homepage, hub and site builds. +- **The sidebar's site picker shows your site from the first paint.** It used to show "All sites" on every page and then jump to the site you had picked, and Dashboard and Channels came up in your site only after a `?site=` had been added to the address. The picked site is now kept in a cookie that the editor reads before it draws a page, so the picker, Dashboard and Channels open in it at once, and the address is left alone. A link that carries `?site=<id>` still opens that page in that site, without changing the one you picked; picking a site on such a page drops the `?site=` from the address. On a site's own pages (Charts, Publish, …) the picker still follows the page, and opening one still makes that site the picked one. The first time you open the editor after updating, a site picked before is moved into the cookie; the picker may show "All sites" for a moment that once. A site picked in one tab reaches the editor's other open tabs without a reload. **New channel** starts with the picked site ticked under its sites (or the site of a `?site=` link), including when it is opened from the editor's own links. Each editor keeps its own pick, as before, when several run on one machine on different ports. ## [0.10.0] - 2026-09-28 - **The homepage can be built and deployed from `/sites`.** Under a new **Homepage** section, after Hub, there is **Build homepage** (tick **Deploy after build** to ship it in the same job, only if the build succeeds) and **Deploy homepage**, which ships the build already in `homepage/out`. A **Preview branch** box beside them sends either deploy to a Cloudflare Pages preview of the `archilyzer` project instead of production, and shows the preview's address as you type; a name Cloudflare would refuse or rewrite, or `main`, greys the deploy buttons out and says why. A line under the buttons says what a deploy would ship: when `homepage/out` was built (or that it holds no build yet), and where it goes, with the live URL. Deploy homepage with nothing built is refused before any job starts. The homepage reads the search index as it stands, so run **Build index** first when its numbers should move. The jobs run the same code as `archilyzer build homepage` / `deploy homepage`, and show on `/jobs` as `build-homepage`, `deploy-homepage` and `build-deploy-homepage`. The Hub section no longer describes the homepage. diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx @@ -96,7 +96,8 @@ type Props = { sites: SiteMembershipOption[]; // Edit mode: the channel's current site memberships. initialMemberships?: InitialMembership[]; - // Create mode: the active site (from ?site=) to pre-check. + // Create mode: the active site, which starts checked (ChannelFormClient: a + // `?site=` link's, else the stored selection). activeSiteId?: string | null; }; diff --git a/editor/app/channels/components/ChannelFormClient.tsx b/editor/app/channels/components/ChannelFormClient.tsx @@ -1,10 +1,12 @@ "use client"; -import { useActionState, useEffect, useState } from "react"; +import { useActionState } from "react"; +import { useSearchParams } from "next/navigation"; import { ChannelForm } from "./ChannelForm"; import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; import type { ActionResult } from "../actions"; -import { ALL_SITES } from "../../lib/activeSite"; +import { resolveActiveSiteFrom } from "../../lib/activeSite"; +import { useSiteScope } from "../../components/SiteScopeProvider"; import type { InitialMembership, SiteMembershipOption, @@ -32,21 +34,18 @@ export function ChannelFormClient({ action, undefined, ); - // The active site is mirrored into the `?site=` URL param by SiteScopeSelect. - // Read it here (avoiding useSearchParams so this form needn't be wrapped in a - // Suspense boundary) so a newly created channel pre-checks that site in the - // Sites membership section. Only meaningful in create mode — an existing - // channel's memberships come from initialMemberships. - const [activeSite, setActiveSite] = useState<string | null>(null); - useEffect(() => { - try { - setActiveSite(new URLSearchParams(window.location.search).get("site")); - } catch { - /* ignore */ - } - }, []); - const activeSiteId = - !initial && activeSite && activeSite !== ALL_SITES ? activeSite : null; + // A new channel starts checked on the active site, as the picker resolves it + // off a site's pages: a `?site=` link's, else the stored selection (the + // cookie, through SiteScopeProvider). Both are known on the server and on the + // first client render, so the box is checked in the first paint. Create mode + // only: an existing channel's memberships come from initialMemberships. + // (useSearchParams needs no Suspense here: both pages that render this form + // are dynamic.) + const { stored, siteIds } = useSiteScope(); + const linkSite = useSearchParams().get("site"); + const activeSiteId = initial + ? null + : resolveActiveSiteFrom([linkSite, stored], siteIds).siteId; return ( <ChannelForm action={formAction} diff --git a/editor/app/channels/components/ChannelVolumeBar.tsx b/editor/app/channels/components/ChannelVolumeBar.tsx @@ -21,10 +21,9 @@ import { LOCATION_FILTER_PARAM } from "yt-dlp-transcript-common/views/storage"; // per-row focus sentence made before the focus bar took it. So it lives here, // one chip per volume, above the rack. // -// THE CHIP IS THE FILTER, and it is a LINK. `?location=` is a URL param beside -// `?site=` for the reason that one is: the page is a server component, the -// filter changes what the server sends, and a link is shareable — /storage -// links straight to a filtered list. Client state would also lose the race with +// THE CHIP IS THE FILTER, and it is a LINK. `?location=` is a URL param because +// the page is a server component, the filter changes what the server sends, +// and a link is shareable — /storage links straight to a filtered list. Client state would also lose the race with // the global AutoRefresh's router.refresh(), which is why the sort is the one // thing here that stays local. @@ -65,8 +64,9 @@ export function ChannelVolumeBar({ const pathname = usePathname(); const params = useSearchParams(); // THE SITE SCOPE SURVIVES THE VOLUME FILTER. They are two independent - // questions ("whose channels" and "which disk") and a chip that silently - // dropped ?site= would answer one by discarding the other. + // questions ("whose channels" and "which disk"). The stored scope is a cookie + // and not in the URL; a `?site=` link's scope is, and a chip that silently + // dropped it would answer one question by discarding the other. const href = (id: string | null): string => { const next = new URLSearchParams(params.toString()); if (id === null) next.delete(LOCATION_FILTER_PARAM); diff --git a/editor/app/channels/components/SiteMembershipsSection.tsx b/editor/app/channels/components/SiteMembershipsSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; // The channel form's per-site membership picker: every configured site with a // checkbox (member or not) plus a group dropdown, including a "+ New group…" @@ -28,8 +28,9 @@ type Props = { sites: SiteMembershipOption[]; // Edit mode: the channel's current memberships (pre-checked). initialMemberships?: InitialMembership[]; - // Create mode: the active site from `?site=` to pre-check. Arrives via a - // mount effect in ChannelFormClient, before any user interaction. + // Create mode: the active site, which starts checked. ChannelFormClient + // resolves it on its first render (a `?site=` link's, else the stored + // selection), so it is part of the initial state below. activeSiteId?: string | null; }; @@ -39,28 +40,28 @@ export function SiteMembershipsSection({ activeSiteId, }: Props) { // Presence in the map = checked. groupId "" = the site's default group. - const [selected, setSelected] = useState<Map<string, Row>>( - () => - new Map( - (initialMemberships ?? []).map((m) => [ - m.siteId, - { groupId: m.groupId ?? "", newGroupName: "" }, - ]), - ), - ); - - // Create-mode pre-check of the active site (mirrors the old hidden - // `activeSite` field's behavior). Fires before user interaction, so no - // clobber guard beyond "already checked" is needed. - useEffect(() => { - if (!activeSiteId || !sites.some((s) => s.siteId === activeSiteId)) return; - setSelected((prev) => { - if (prev.has(activeSiteId)) return prev; - const next = new Map(prev); - next.set(activeSiteId, { groupId: "", newGroupName: "" }); - return next; - }); - }, [activeSiteId, sites]); + // + // Create mode starts with the active site checked (mirrors the old hidden + // `activeSite` field's behavior). It is INITIAL state, not an effect: the + // server renders the box checked, so there is no unchecked first paint, and a + // later refresh (the pulse, or a site picked in another tab) cannot re-check a + // box the user has cleared. + const [selected, setSelected] = useState<Map<string, Row>>(() => { + const rows = new Map( + (initialMemberships ?? []).map((m) => [ + m.siteId, + { groupId: m.groupId ?? "", newGroupName: "" }, + ]), + ); + if ( + activeSiteId && + !rows.has(activeSiteId) && + sites.some((s) => s.siteId === activeSiteId) + ) { + rows.set(activeSiteId, { groupId: "", newGroupName: "" }); + } + return rows; + }); if (sites.length === 0) { // No hidden field at all: the actions skip membership reconciliation. diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -56,7 +56,7 @@ import { } from "yt-dlp-transcript-common/views/channelGroupSections"; import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton"; import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton"; -import { resolveActiveSite } from "../lib/activeSite"; +import { readActiveSite } from "../lib/activeSiteServer"; export const dynamic = "force-dynamic"; @@ -167,7 +167,8 @@ export default async function ChannelsPage({ const paths = getPaths(); const settings = getSettings(); const { site, location: locationParam, sort: sortParam } = await searchParams; - const active = resolveActiveSite(site, listSiteIds(paths)); + // The active site: a `?site=` link's for this request, else the cookie's. + const active = await readActiveSite(site, listSiteIds(paths)); // Counts come from each channel's last snapshot, not a corpus walk. One read // serves both the table and the freshness footer below. const briefs = await listChannelBriefs(paths); diff --git a/editor/app/components/SiteScopeProvider.tsx b/editor/app/components/SiteScopeProvider.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { + ACTIVE_SITE_CHANNEL, + ACTIVE_SITE_KEY, + isStorableActiveSite, + siteIdFromPathname, +} from "../lib/activeSite"; +import { setActiveSiteAction } from "../lib/activeSiteActions"; + +// THE SITE SCOPE, supplied once by the root layout (see app/lib/activeSite.ts). +// +// `activeSite` is the cookie's selection, resolved by the layout's +// readActiveSite() on the server, so the server and the first client render +// agree and the picker paints the stored site at once. `stored` below starts +// from it and moves ahead of it optimistically when a choice is made; when the +// server's value changes (the re-render a cookie write triggers, or a refresh +// after another tab chose), it follows that. +// +// OTHER TABS. The cookie is shared by every tab of this origin, but a tab's +// `stored` comes from its root layout, which a client-side navigation does not +// re-render: a site picked in one tab left the others showing the old one over +// pages that read the new one. So every successful write is announced on a +// BroadcastChannel (ACTIVE_SITE_CHANNEL; per origin, so per port, like the +// cookie's name), and the other tabs answer with router.refresh(): the layout +// and the page re-render with the cookie as it is now, and the tab's router +// cache is dropped. A channel does not deliver a message to the instance that +// posted it, so a tab does not refresh for its own write. A browser without +// BroadcastChannel keeps each tab's value until its next full load. +// +// Two effects, and neither rewrites a URL: +// - visiting a site's own page (/sites/<id>/…) records that site, so Dashboard +// and Channels follow — the path already shows it, so there is no flash; +// - the one-time MIGRATION: a visitor with the old localStorage key and no +// cookie gets it copied into the cookie, then the key is removed. That is the +// only localStorage read, and the one paint it may cost is on the first visit +// after the update. +// Both only WRITE the cookie (`record`); the value comes back through the +// server's re-render. They run as the page hydrates, and a state change there +// would re-render the picker before React replays a choice made on the +// server-rendered select before hydration: the re-render resets the select to +// its old value, and the replayed change reads that instead of the choice. +type SiteScope = { + siteIds: string[]; + // The stored selection: a site id or "__all__". The picker resolves it + // against the path and a `?site=` param; it is not the effective scope alone. + stored: string; + // Store a choice: shown at once, written through setActiveSiteAction. Resolves + // once the cookie is set (and the server has re-rendered with it) to true, or + // to false with the previous selection put back when the write failed. + choose: (value: string) => Promise<boolean>; +}; + +const SiteScopeContext = createContext<SiteScope | null>(null); + +export function SiteScopeProvider({ + activeSite, + fromCookie, + siteIds, + children, +}: { + activeSite: string; + // Whether the request carried the cookie at all (the migration's trigger). + fromCookie: boolean; + siteIds: string[]; + children: ReactNode; +}) { + const [stored, setStored] = useState(activeSite); + // Follow the server's value when IT changes — not on every render, so an + // optimistic choice is not undone by a refresh that raced the cookie write — + // and not while a write is in flight: two quick choices re-render twice, and + // the first one's render must not paint over the second choice. + const [seen, setSeen] = useState(activeSite); + const [writing, setWriting] = useState(0); + if (seen !== activeSite) { + setSeen(activeSite); + if (writing === 0) setStored(activeSite); + } + // What `stored` is now, for the effects below without making them re-run on + // it. Synced after commit (declared first, so it runs before them), and moved + // at once by `choose`. + const storedRef = useRef(stored); + useEffect(() => { + storedRef.current = stored; + }, [stored]); + + // The other tabs (see above): subscribe, and keep the one instance to post on. + const router = useRouter(); + const channel = useRef<BroadcastChannel | null>(null); + useEffect(() => { + if (typeof BroadcastChannel === "undefined") return; + const ch = new BroadcastChannel(ACTIVE_SITE_CHANNEL); + channel.current = ch; + ch.onmessage = () => router.refresh(); + return () => { + ch.close(); + if (channel.current === ch) channel.current = null; + }; + }, [router]); + const announce = useCallback((value: string) => { + try { + channel.current?.postMessage(value); + } catch { + /* closed while unmounting: nothing to tell */ + } + }, []); + + const choose = useCallback(async (value: string): Promise<boolean> => { + const previous = storedRef.current; + storedRef.current = value; + setStored(value); + setWriting((n) => n + 1); + let ok = false; + try { + ok = await setActiveSiteAction(value); + } catch { + ok = false; + } + setWriting((n) => n - 1); + if (ok) { + announce(value); + } else { + storedRef.current = previous; + setStored(previous); + } + return ok; + }, [announce]); + + // Write a value the page did not choose — no optimistic state (see above). + const record = useCallback(async (value: string): Promise<boolean> => { + let ok = false; + try { + ok = await setActiveSiteAction(value); + } catch { + ok = false; + } + if (ok) announce(value); + return ok; + }, [announce]); + + // Visiting a site's own page records it: once per arrival at that site's + // pages (a re-run of the effect — StrictMode, Fast Refresh — does not write + // again), and not when the store already holds it. + const pathname = usePathname(); + const pathSite = siteIdFromPathname(pathname)?.siteId ?? null; + const known = pathSite !== null && siteIds.includes(pathSite); + const recordedFor = useRef<string | null>(null); + useEffect(() => { + if (!known || pathSite === null) { + recordedFor.current = null; + return; + } + if (recordedFor.current === pathSite) return; + recordedFor.current = pathSite; + if (pathSite === storedRef.current) return; + void record(pathSite); + }, [known, pathSite, record]); + + // The one-time move from localStorage. Guarded by a ref, not by deps: it is + // about this page load, and StrictMode's second mount must not repeat it. + const migrated = useRef(false); + useEffect(() => { + if (migrated.current) return; + migrated.current = true; + const forget = () => { + try { + window.localStorage.removeItem(ACTIVE_SITE_KEY); + } catch { + /* storage unavailable: nothing to forget */ + } + }; + // A cookie already decides, and on a site's page the path does (the effect + // above records it): the old key has nothing left to say. + if (fromCookie || known) { + forget(); + return; + } + let legacy: string | null = null; + try { + legacy = window.localStorage.getItem(ACTIVE_SITE_KEY); + } catch { + /* storage unavailable */ + } + if (!isStorableActiveSite(legacy)) { + forget(); + return; + } + // Removed only once the cookie holds it, so a failed write loses nothing. + void record(legacy).then((ok) => { + if (ok) forget(); + }); + // Once per page load, on purpose (see the ref). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const value = useMemo( + () => ({ siteIds, stored, choose }), + [siteIds, stored, choose], + ); + return ( + <SiteScopeContext.Provider value={value}> + {children} + </SiteScopeContext.Provider> + ); +} + +export function useSiteScope(): SiteScope { + const scope = useContext(SiteScopeContext); + if (!scope) { + throw new Error("useSiteScope() outside SiteScopeProvider (app/layout.tsx)"); + } + return scope; +} diff --git a/editor/app/components/SiteScopeSelect.tsx b/editor/app/components/SiteScopeSelect.tsx @@ -1,84 +1,62 @@ "use client"; -import { useEffect } from "react"; +import { useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { - ACTIVE_SITE_KEY, ALL_SITES, - resolveActiveSite, + resolveActiveSiteFrom, siteIdFromPathname, + withoutSiteParam, } from "../lib/activeSite"; +import { useSiteScope } from "./SiteScopeProvider"; export type SiteScopeOption = { siteId: string; siteTitle: string }; -// The single global site selector shown in the sidebar. Persists the choice in -// localStorage (the source of truth) and mirrors it into the `?site=` URL param -// so the server-rendered scoped pages (Dashboard, Channels) can read it from -// their `searchParams`. On a site's own pages (`/sites/<id>/…`) the path IS the -// selection: the picker shows it, writes it to storage so Dashboard and Channels -// follow, and changing it navigates to the same tab of the other site. See +// The single global site selector shown in the sidebar. The stored selection is +// a cookie, read by the root layout and supplied by SiteScopeProvider, so this +// renders the right value on the FIRST paint, on the server and on every +// navigation — it has no effect that rewrites the URL and reads no storage. See // app/lib/activeSite.ts. +// +// What it shows, first match wins: the choice just made on this URL (until the +// navigation it starts lands), the site a /sites/<id>/… path names, a valid +// `?site=` on the URL (a link's scope for that page), the stored selection. export function SiteScopeSelect({ sites }: { sites: SiteScopeOption[] }) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const siteIds = sites.map((s) => s.siteId); - const urlValue = searchParams.get("site"); + const { siteIds, stored, choose } = useSiteScope(); + const search = searchParams.toString(); + const param = searchParams.get("site"); const onSite = siteIdFromPathname(pathname); - const resolved = resolveActiveSite(onSite?.siteId ?? urlValue, siteIds); + // A controlled <select> snaps back to its value when the change does not + // re-render it with the new one. Where the path or the param outranks the + // stored value, the choice is held here for the URL it was made on, so the + // select shows it until the navigation it starts replaces that URL. Once the + // URL has moved it is dropped, so coming back to that URL later (Back, or a + // link) shows what the URL says, not an old choice. + const urlKey = `${pathname}?${search}`; + const [pending, setPending] = useState<{ urlKey: string; value: string } | null>( + null, + ); + if (pending !== null && pending.urlKey !== urlKey) setPending(null); + const held = pending?.urlKey === urlKey ? pending.value : null; + const resolved = resolveActiveSiteFrom( + [held, onSite?.siteId, param, stored], + siteIds, + ); const multi = sites.length > 1; - // The /sites CRUD pages manage every site and never read ?site=; seeding it - // there would only let the mount-effect replace() below clobber an in-flight - // push to /sites/<id> (a link click on the list), bouncing the user back to - // /sites?site=<id>. Only the scoped server pages (Dashboard, Channels) consume - // the param, so restrict the seed to non-/sites routes. On `/sites/<id>/…` the - // picker reads the path instead (below) and never seeds either. - const seedsSiteParam = !pathname.startsWith("/sites"); - - function setParam(value: string) { - const params = new URLSearchParams(searchParams.toString()); - params.set("site", value); - router.replace(`${pathname}?${params.toString()}`, { scroll: false }); - } - // Keep URL and localStorage reconciled. An explicit, valid URL param wins (and - // is written back to localStorage); otherwise seed the URL from the stored - // preference so server components pick up the active site on navigation. - useEffect(() => { - if (siteIds.length === 0) return; - // The path wins over the param: on a site's own pages it IS the selection, - // and writing it to storage is what makes Dashboard and Channels follow. - const chosen = onSite?.siteId ?? urlValue; - const chosenIsValid = - chosen === ALL_SITES || (!!chosen && siteIds.includes(chosen)); - if (chosenIsValid) { - try { - window.localStorage.setItem(ACTIVE_SITE_KEY, chosen as string); - } catch { - /* ignore */ - } - return; - } - if (!seedsSiteParam) return; - let stored: string | null = null; - try { - stored = window.localStorage.getItem(ACTIVE_SITE_KEY); - } catch { - /* ignore */ - } - setParam(resolveActiveSite(stored, siteIds).value); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlValue, pathname, siteIds.join(",")]); - - function onChange(value: string) { - try { - window.localStorage.setItem(ACTIVE_SITE_KEY, value); - } catch { - /* ignore */ - } + async function onChange(value: string) { if (onSite) { - // The site is the path here, so changing it is a navigation, not a param: - // the same tab of the other site, or the family page for "All sites". + // The site is the path here, so changing it is a navigation: the same tab + // of the other site, or the family page for "All sites". The cookie is + // written FIRST, so a page opened right after the URL moves reads it. + setPending({ urlKey, value }); + if (!(await choose(value))) { + setPending(null); + return; + } router.push( value === ALL_SITES ? "/sites" @@ -86,7 +64,20 @@ export function SiteScopeSelect({ sites }: { sites: SiteScopeOption[] }) { ); return; } - setParam(value); + if (param === null) { + // The common case: the stored value is what shows, and the cookie write + // re-renders this page (Dashboard, Channels) in the new scope. + await choose(value); + return; + } + // A `?site=` link's page: the choice replaces the link's scope, so the + // param goes — after the cookie is written, so the new URL renders with it. + setPending({ urlKey, value }); + if (!(await choose(value))) { + setPending(null); + return; + } + router.replace(withoutSiteParam(pathname, search), { scroll: false }); } if (sites.length === 0) { @@ -102,7 +93,7 @@ export function SiteScopeSelect({ sites }: { sites: SiteScopeOption[] }) { <span className="text-xs uppercase tracking-wide text-muted-foreground">site</span> <select value={resolved.value} - onChange={(e) => onChange(e.target.value)} + onChange={(e) => void onChange(e.target.value)} aria-label="Active site" className="rounded border border-border bg-card px-2 py-1 text-sm" > diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -18,6 +18,8 @@ import { AutoRefresh } from "./components/AutoRefresh"; import { CommandPalette } from "./components/CommandPalette"; import { ChangelogNavLink } from "./components/ChangelogNavLink"; import { SiteScopeSelect } from "./components/SiteScopeSelect"; +import { SiteScopeProvider } from "./components/SiteScopeProvider"; +import { readActiveSite } from "./lib/activeSiteServer"; import { Toaster } from "yt-dlp-transcript-common/components/ui/sonner"; import { NAV_GROUPS, type NavLink } from "./lib/nav"; import { CleanableBadge } from "./components/CleanableBadge"; @@ -62,6 +64,12 @@ export default async function RootLayout({ siteId: s.siteId, siteTitle: s.siteTitle, })); + const siteIds = sites.map((s) => s.siteId); + // THE SITE SCOPE, read once per request from its cookie and supplied to the + // picker (and anything else that asks) by SiteScopeProvider. A layout has no + // searchParams, so a `?site=` link is applied by the picker from the URL and + // by the scoped pages from their own searchParams. See app/lib/activeSite.ts. + const scope = await readActiveSite(null, siteIds); const navItemClass = "px-2.5 py-1.5 rounded-md text-foreground/80 hover:text-foreground hover:bg-muted whitespace-nowrap flex items-center gap-2 transition-colors"; const renderLink = (link: NavLink) => { @@ -128,6 +136,11 @@ export default async function RootLayout({ <body className="min-h-full flex flex-col md:flex-row bg-background text-foreground"> <ThemeScript /> <ThemeProvider> + <SiteScopeProvider + activeSite={scope.value} + fromCookie={scope.stored !== null} + siteIds={siteIds} + > <AppFrame sidebar={ <aside className="md:w-56 md:shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-border bg-card flex flex-col"> @@ -155,6 +168,11 @@ export default async function RootLayout({ <ThemeToggle /> </div> </div> + {/* The picker reads useSearchParams() (a `?site=` link). This + layout reads a cookie, so every page renders per request and + the hook does not suspend on the server: the fallback is never + what the first paint shows. The boundary stays for the + production build's missing-Suspense check. */} <Suspense fallback={null}> <SiteScopeSelect sites={sites} /> </Suspense> @@ -196,6 +214,7 @@ export default async function RootLayout({ > {children} </AppFrame> + </SiteScopeProvider> </ThemeProvider> </body> </html> diff --git a/editor/app/lib/activeSite.test.ts b/editor/app/lib/activeSite.test.ts @@ -1,6 +1,14 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { siteIdFromPathname } from "./activeSite"; +import { + ACTIVE_SITE_COOKIE, + ALL_SITES, + activeSiteCookieName, + isStorableActiveSite, + resolveActiveSiteFrom, + siteIdFromPathname, + withoutSiteParam, +} from "./activeSite"; // Run with: pnpm -C editor exec tsx --test "app/**/*.test.ts" // @@ -37,3 +45,78 @@ test("routes outside /sites name no site", () => { test("a deeper path than a tab names no site", () => { assert.equal(siteIdFromPathname("/sites/a/b/c"), null); }); + +// ── The cookie (release 15 slice SS) ───────────────────────────────────────── +// +// The store is a cookie read on the server; these are its pure rules: the name a +// request uses, what may be written, and which value wins. + +test("the cookie name carries the port, so two editors on one host keep one each", () => { + assert.equal(activeSiteCookieName("localhost:3001"), `${ACTIVE_SITE_COOKIE}-3001`); + assert.equal(activeSiteCookieName("127.0.0.1:3411"), `${ACTIVE_SITE_COOKIE}-3411`); + assert.equal(activeSiteCookieName("[::1]:3101"), `${ACTIVE_SITE_COOKIE}-3101`); +}); + +test("a host with no port, or no host, uses the base name", () => { + assert.equal(activeSiteCookieName("editor.example.org"), ACTIVE_SITE_COOKIE); + assert.equal(activeSiteCookieName(null), ACTIVE_SITE_COOKIE); + assert.equal(activeSiteCookieName(undefined), ACTIVE_SITE_COOKIE); + assert.equal(activeSiteCookieName("[::1]"), ACTIVE_SITE_COOKIE); +}); + +test("a site id or the all-sites sentinel may be stored", () => { + assert.equal(isStorableActiveSite("alpha"), true); + assert.equal(isStorableActiveSite("a-1"), true); + assert.equal(isStorableActiveSite(ALL_SITES), true); +}); + +test("anything else is refused: the value goes into a header", () => { + for (const bad of [ + "", + "Alpha", + "-alpha", + "a b", + "a;b", + "a=b", + "a\r\nSet-Cookie: x=y", + "_homepage", + "a".repeat(129), + null, + undefined, + 42, + ]) { + assert.equal(isStorableActiveSite(bad), false, JSON.stringify(bad)); + } + assert.equal(isStorableActiveSite("a".repeat(128)), true); +}); + +test("the first candidate naming a configured site wins", () => { + const ids = ["alpha", "beta"]; + // A `?site=` link beats the cookie. + assert.equal(resolveActiveSiteFrom(["beta", "alpha"], ids).value, "beta"); + // No param: the cookie. + assert.equal(resolveActiveSiteFrom([null, "alpha"], ids).value, "alpha"); + assert.equal(resolveActiveSiteFrom([undefined, "alpha"], ids).siteId, "alpha"); + // The sentinel is a real choice, not a miss. + assert.equal(resolveActiveSiteFrom([ALL_SITES, "alpha"], ids).isAll, true); +}); + +test("a param naming no site falls through to the cookie", () => { + const ids = ["alpha", "beta"]; + assert.equal(resolveActiveSiteFrom(["gone", "beta"], ids).value, "beta"); +}); + +test("nothing valid resolves to the default: the lone site, else all sites", () => { + assert.equal(resolveActiveSiteFrom([null, "gone"], ["alpha", "beta"]).value, ALL_SITES); + assert.equal(resolveActiveSiteFrom([null, null], ["solo"]).value, "solo"); + assert.equal(resolveActiveSiteFrom([], []).value, ALL_SITES); +}); + +test("dropping the site param keeps every other param", () => { + assert.equal(withoutSiteParam("/channels", "site=alpha"), "/channels"); + assert.equal( + withoutSiteParam("/channels", "site=alpha&location=internal&sort=size"), + "/channels?location=internal&sort=size", + ); + assert.equal(withoutSiteParam("/", ""), "/"); +}); diff --git a/editor/app/lib/activeSite.ts b/editor/app/lib/activeSite.ts @@ -1,25 +1,73 @@ // Shared notion of "the site I'm working on" for the editor's site-scoped views -// (Dashboard, Channels). The selection is persisted client-side in localStorage -// by SiteScopeSelect, but those pages are server components, so the selection is -// mirrored into the URL `?site=` query param and read here from the page's -// `searchParams`. This module has NO server-only imports so it can be shared by -// the client selector too. +// (Dashboard, Channels) and the sidebar's "Active site" picker. This module has +// NO server-only imports so the client picker and provider can share it. // -// ON A SITE'S OWN PAGES the site is the PATH, not the param: /sites/<id>/<tab> -// names it, the tab reads `params.siteId`, and the picker reads the same path -// through `siteIdFromPathname` so the two never disagree. +// THE STORE IS A COOKIE (release 15 slice SS). The root layout reads it once per +// request (`readActiveSite()`, app/lib/activeSiteServer.ts) and hands it to +// `SiteScopeProvider`; Dashboard and Channels read it through the same helper. +// So the server renders the picker, and the scoped pages, with the stored +// selection on the first paint — nothing is reconciled after it. It is written +// only by `setActiveSiteAction` (app/lib/activeSiteActions.ts). +// +// A `?site=<id>` LINK still works: a valid param governs that one request (the +// server pages read it from `searchParams`, the picker from the URL), and is not +// written to the cookie. The editor's own navigation no longer appends it. +// +// ON A SITE'S OWN PAGES the site is the PATH, not the cookie or the param: +// /sites/<id>/<tab> names it, the tab reads `params.siteId`, and the picker +// reads the same path through `siteIdFromPathname` so the two never disagree. +// Visiting one records that site in the cookie, so Dashboard and Channels follow. -// Sentinel param value meaning "all sites" (full channel pool). A bare string so -// it can never collide with a real siteId (which matches SITE_ID_RE). +// Sentinel value meaning "all sites" (full channel pool). A bare string so it +// can never collide with a real siteId (which matches SITE_ID_RE). export const ALL_SITES = "__all__"; -// localStorage key the selector reads/writes. +// The localStorage key the picker used before the cookie. Read ONCE, by +// SiteScopeProvider's migration, when a visitor has it and no cookie; then +// removed. Nothing else reads it. export const ACTIVE_SITE_KEY = "activeSite"; +// The cookie's base name. The name a request uses carries the port it was made +// to (`activeSiteCookieName`), because a cookie is shared by every port on a +// host while localStorage was per origin: two editors on one machine (the live +// one and a worktree's) keep a selection each, as they did. +export const ACTIVE_SITE_COOKIE = "archilyzer-active-site"; + +// The BroadcastChannel a successful write is announced on, so the editor's +// other tabs refresh (SiteScopeProvider). A channel is per origin, so each port +// has its own, like the cookie's name. +export const ACTIVE_SITE_CHANNEL = "archilyzer-active-site"; + +// One year, in seconds (the cookie's `maxAge`). +export const ACTIVE_SITE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +// "localhost:3001" → "archilyzer-active-site-3001"; a host with no port (the +// default port, behind a proxy) or no host at all → the base name. +export function activeSiteCookieName(host: string | null | undefined): string { + const port = host ? /:(\d+)$/.exec(host)?.[1] : undefined; + return port ? `${ACTIVE_SITE_COOKIE}-${port}` : ACTIVE_SITE_COOKIE; +} + +// SITE_ID_RE re-spelled (common/lib/siteSchema.ts imports zod and is +// server-only), with a length cap because the value goes into a header. +const SITE_ID_SHAPE = /^[a-z0-9][a-z0-9-]*$/; +const STORED_MAX_LENGTH = 128; + +// What may be stored: ALL_SITES or anything shaped like a site id. Whether the +// id names a site is decided when it is read (`resolveActiveSite`), so a site +// deleted since resolves as a missing selection does. +export function isStorableActiveSite(value: unknown): value is string { + return ( + typeof value === "string" && + (value === ALL_SITES || + (value.length <= STORED_MAX_LENGTH && SITE_ID_SHAPE.test(value))) + ); +} + export type ResolvedActiveSite = { // All configured site ids (passed in by the caller). siteIds: string[]; - // The canonical param value for the resolved selection: a siteId or ALL_SITES. + // The canonical value for the resolved selection: a siteId or ALL_SITES. value: string; // True when the selection spans every site (full pool). isAll: boolean; @@ -27,7 +75,7 @@ export type ResolvedActiveSite = { siteId: string | null; }; -// Resolve the raw `searchParams.site` value against the configured site ids. +// Resolve one raw value (a param, a cookie) against the configured site ids. // - a valid siteId -> that site // - ALL_SITES -> all sites (full pool) // - missing/invalid (default) -> the lone site if exactly one, else all sites @@ -54,10 +102,23 @@ export function resolveActiveSite( return all; } +// THE PRECEDENCE, in one place: the first candidate that names a configured +// site (or ALL_SITES) wins; when none does, the default above. The server +// passes [?site=, cookie]; the picker passes [its pending choice, the path's +// site, ?site=, the stored value]. +export function resolveActiveSiteFrom( + candidates: ReadonlyArray<string | null | undefined>, + siteIds: string[], +): ResolvedActiveSite { + const chosen = candidates.find( + (c) => c === ALL_SITES || (!!c && siteIds.includes(c)), + ); + return resolveActiveSite(chosen, siteIds); +} + // The routes under /sites where the path names the site. `new` is /sites/new, // a static route that beats [siteId] and is not a site. The id pattern is -// SITE_ID_RE re-spelled: common/lib/site.ts imports node:fs and this module -// must stay importable from the client picker. +// SITE_ID_RE re-spelled, as above. const SITE_PATH_RE = /^\/sites\/([a-z0-9][a-z0-9-]*)(?:\/([a-z-]+))?\/?$/; export type SitePath = { siteId: string; segment: string | null }; @@ -69,3 +130,12 @@ export function siteIdFromPathname(pathname: string): SitePath | null { if (!m || m[1] === "new") return null; return { siteId: m[1], segment: m[2] ?? null }; } + +// The same URL with its `site` param dropped (every other param kept, in +// order): where the picker goes when a choice replaces a `?site=` link's scope. +export function withoutSiteParam(pathname: string, search: string): string { + const params = new URLSearchParams(search); + params.delete("site"); + const q = params.toString(); + return q ? `${pathname}?${q}` : pathname; +} diff --git a/editor/app/lib/activeSiteActions.ts b/editor/app/lib/activeSiteActions.ts @@ -0,0 +1,31 @@ +"use server"; + +import { cookies } from "next/headers"; +import { + ACTIVE_SITE_COOKIE_MAX_AGE, + isStorableActiveSite, +} from "./activeSite"; +import { activeSiteCookieForRequest } from "./activeSiteServer"; + +// THE ONE WRITER of the active-site cookie (see ./activeSite.ts). Called by the +// picker's onChange, by SiteScopeProvider when a site's own page is visited, and +// once by its localStorage migration. Setting a cookie in a server action makes +// Next re-render the current page and its layouts with the new value, so the +// root layout hands the provider the new selection and Dashboard or Channels +// re-scope, with no refresh of our own. +// +// The value is only SHAPE-checked (a site id or "__all__"): whether it names a +// configured site is decided at every read, so a site deleted later resolves as +// no selection. Returns false, writing nothing, for anything else. +export async function setActiveSiteAction(value: string): Promise<boolean> { + if (!isStorableActiveSite(value)) return false; + const name = await activeSiteCookieForRequest(); + (await cookies()).set(name, value, { + path: "/", + sameSite: "lax", + maxAge: ACTIVE_SITE_COOKIE_MAX_AGE, + // Only the server reads it: the page gets it through the layout. + httpOnly: true, + }); + return true; +} diff --git a/editor/app/lib/activeSiteServer.ts b/editor/app/lib/activeSiteServer.ts @@ -0,0 +1,35 @@ +import "server-only"; +import { cookies, headers } from "next/headers"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { listSiteIds } from "yt-dlp-transcript-common/lib/site"; +import { + activeSiteCookieName, + resolveActiveSiteFrom, + type ResolvedActiveSite, +} from "./activeSite"; + +// THE ONE READ of the active site on the server (see ./activeSite.ts). The root +// layout calls it with no param (a layout has no searchParams) and hands the +// result to SiteScopeProvider; Dashboard and Channels call it with their +// `searchParams.site`, which wins for that request when it names a site or +// "__all__". Nothing is written here: a server component cannot set a cookie, +// and only setActiveSiteAction does. +export type ActiveSiteRead = ResolvedActiveSite & { + // The cookie's raw value, or null when the request carried none — which is + // what tells the provider a visitor may still have the old localStorage key. + stored: string | null; +}; + +// The request's own cookie name (per port; see activeSiteCookieName). +export async function activeSiteCookieForRequest(): Promise<string> { + return activeSiteCookieName((await headers()).get("host")); +} + +export async function readActiveSite( + param?: string | null, + siteIds: string[] = listSiteIds(getPaths()), +): Promise<ActiveSiteRead> { + const name = await activeSiteCookieForRequest(); + const stored = (await cookies()).get(name)?.value ?? null; + return { ...resolveActiveSiteFrom([param, stored], siteIds), stored }; +} diff --git a/editor/app/page.tsx b/editor/app/page.tsx @@ -13,7 +13,7 @@ import { getActionableSummary, widgetActionableRows, } from "./lib/actionable/loadActionable"; -import { resolveActiveSite } from "./lib/activeSite"; +import { readActiveSite } from "./lib/activeSiteServer"; import { buildActiveJobsPayload } from "./jobs/active/buildActiveJobs"; import { buildWorkersPayload } from "./workers/buildWorkers"; import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; @@ -52,8 +52,9 @@ export default async function Dashboard({ searchParams: Promise<{ site?: string }>; }) { const paths = getPaths(); + // The active site: a `?site=` link's for this request, else the cookie's. const { site } = await searchParams; - const active = resolveActiveSite(site, listSiteIds(paths)); + const active = await readActiveSite(site, listSiteIds(paths)); const summary = await getActionableSummary(paths); // Scope the channel-derived data to the active site's membership. Under "all diff --git a/editor/e2e/site-scope.spec.ts b/editor/e2e/site-scope.spec.ts @@ -1,5 +1,7 @@ -import { test, expect } from "@playwright/test"; -import { readJson, resetData, writeSite } from "./helpers"; +import { test, expect, type Page } from "@playwright/test"; +import { activeSiteCookieName } from "../app/lib/activeSite"; +import { baseUrl } from "./baseUrl"; +import { readJson, resetData, writeSettings, writeSite } from "./helpers"; type SiteFile = { channels: { slug: string }[] }; @@ -31,15 +33,19 @@ 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); + await expectNoSiteParam(page); - // 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); + await expectNoSiteParam(page); // Switching to beta flips the scope. await page.getByLabel("Active site").selectOption("beta"); @@ -70,10 +76,12 @@ 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$/); + await expectNoSiteParam(page); // "All sites" is the family page. await page.goto("/sites/beta/charts"); await page.getByLabel("Active site").selectOption("__all__"); @@ -138,3 +146,317 @@ 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; +} + +// No `?site=` on the URL once the page has hydrated and settled, read with no +// retry. `toHaveURL` alone would pass on its first poll, before a `replace` from +// an effect (how the old picker seeded the param) could land. +async function expectNoSiteParam(page: Page) { + await hydrated(page); + await page.waitForTimeout(SETTLE_MS); + expect(new URL(page.url()).searchParams.has("site"), page.url()).toBe(false); +} + +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"]); + expect(new URL(page.url()).searchParams.has("site"), page.url()).toBe(false); + } + + // 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", + ]); + expect(new URL(page.url()).searchParams.has("site"), page.url()).toBe(false); + + // 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"); +}); + +test("Back to a site's page shows that site, not the choice made there", async ({ + page, +}) => { + await twoSites(); + const picker = page.getByLabel("Active site"); + await page.goto("/sites/alpha/charts"); + await hydrated(page); + // The choice is held on the page it was made on until the push lands… + await picker.selectOption("beta"); + await expect(page).toHaveURL(/\/sites\/beta\/charts$/); + // …and dropped once the URL has moved: back on alpha's page, the path rules. + await page.goBack(); + await expect(page).toHaveURL(/\/sites\/alpha\/charts$/); + await expect(picker).toHaveValue("alpha"); +}); + +// A site picked in one tab reaches the others. The cookie is shared, but a +// tab's picker reads its root layout, which a client-side navigation does not +// re-render; without the broadcast, tab A kept showing alpha over beta's pages +// and skipped recording a visit to alpha's page. +test("a site picked in another tab reaches this one", async ({ context }) => { + await twoSites(); + // Passive refresh off: a tree refresh whenever the pulse moves would also + // re-read the layout, and only the broadcast may move tab A here. + await writeSettings({ autoRefreshIntervalSeconds: 0 }); + const a = await context.newPage(); + const pickerA = a.getByLabel("Active site"); + await a.goto("/channels"); + await pickerA.selectOption("alpha"); + await expect(a.getByRole("link", { name: "slow-b" })).toHaveCount(0); + await a.goto("/settings"); + await hydrated(a); + + const b = await context.newPage(); + await b.goto("/channels"); + await b.getByLabel("Active site").selectOption("beta"); + await expect(b.getByRole("link", { name: "slow-a" })).toHaveCount(0); + expect(await storedCookie(b)).toBe("beta"); + + // Tab A follows without a reload, and its next page agrees with its picker. + await expect(pickerA).toHaveValue("beta"); + await a.locator("aside nav").getByRole("link", { name: "Channels", exact: true }).click(); + await expect(a).toHaveURL(/\/channels$/); + await expect(pickerA).toHaveValue("beta"); + await expect(a.getByRole("link", { name: "slow-b" })).toBeVisible(); + await expect(a.getByRole("link", { name: "slow-a" })).toHaveCount(0); + + // And a client-side visit to alpha's page is recorded again: tab A no longer + // believes alpha is stored. + await a.locator("aside nav").getByRole("link", { name: "Sites", exact: true }).click(); + await expect(a).toHaveURL(/\/sites$/); + await a.locator('main a[href="/sites/alpha"]').click(); + await expect(a).toHaveURL(/\/sites\/alpha$/); + await expect.poll(() => storedCookie(a), { timeout: 15_000 }).toBe("alpha"); + // Tab B hears of that write too. + await expect(b.getByLabel("Active site")).toHaveValue("alpha"); +}); + +// A new channel starts checked on the active site: the stored one, or a +// `?site=` link's instead (not both). It is the form's initial state, so it is +// in the first paint, read here with no retry, and a refresh later (a pick in +// another tab) does not re-check a box the user cleared. +test("a new channel starts checked on the stored site, or on a ?site= link's", async ({ + context, +}) => { + test.setTimeout(60_000); + await twoSites(); + await writeSettings({ autoRefreshIntervalSeconds: 0 }); + const a = await context.newPage(); + await a.goto("/channels"); + await a.getByLabel("Active site").selectOption("alpha"); + await expect(a.getByRole("link", { name: "slow-b" })).toHaveCount(0); + + const alpha = a.getByLabel("Include on Alpha"); + const beta = a.getByLabel("Include on Beta"); + await a.goto("/channels/new", { waitUntil: "commit" }); + await a.waitForLoadState("domcontentloaded"); + expect(await alpha.isChecked(), "stored site, first paint").toBe(true); + expect(await beta.isChecked()).toBe(false); + + await a.goto("/channels/new?site=beta", { waitUntil: "commit" }); + await a.waitForLoadState("domcontentloaded"); + expect(await beta.isChecked(), "the link's site, first paint").toBe(true); + expect(await alpha.isChecked(), "not the stored one as well").toBe(false); + + // Cleared by hand, it stays cleared when another tab's pick refreshes this one. + await a.goto("/channels/new"); + await expect + .poll(() => + alpha.evaluate((el) => + Object.keys(el).some((k) => k.startsWith("__reactProps")), + ), + ) + .toBe(true); + await alpha.click(); + await expect(a.getByLabel("Group for Alpha")).toHaveCount(0); + const b = await context.newPage(); + await b.goto("/channels"); + await b.getByLabel("Active site").selectOption("beta"); + await expect(b.getByRole("link", { name: "slow-a" })).toHaveCount(0); + await expect(a.getByLabel("Active site")).toHaveValue("beta"); + await a.waitForTimeout(SETTLE_MS); + expect(await alpha.isChecked()).toBe(false); + expect(await beta.isChecked()).toBe(false); +}); diff --git a/editor/next.config.ts b/editor/next.config.ts @@ -80,7 +80,8 @@ const nextConfig: NextConfig = { // site's tab: the value regex is SITE_ID_RE, anchored by Next, so // ?site=__all__ (underscores) misses it and lands on the family page. The // matched query is NOT stripped — /charts?site=a lands on - // /sites/a/charts?site=a — which the tab ignores and the picker reconciles. + // /sites/a/charts?site=a — which the tab ignores, and the picker too: on a + // site's own pages the path is the selection (app/lib/activeSite.ts). // Order matters: first match wins, so each `has` rule precedes its bare one. // // TEMPORARY, not permanent: a 308 is cached by the browser forever, and this diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -3212,6 +3212,53 @@ IS the path segment, the reconcile effect writes it to localStorage (so Dashboar follow), `onChange` does `router.push("/sites/<other>/<segment>")` and "All sites" pushes `/sites`. +**Superseded by release 15 slice SS (2026-09-29): the active site is a cookie.** +`seedsSiteParam`, the reconcile effect and every localStorage read on the render path are gone; +`siteIdFromPathname` and the path rule stand. +- **Store:** the cookie `archilyzer-active-site-<port>` (`activeSiteCookieName(host)` in + `app/lib/activeSite.ts`; base name `ACTIVE_SITE_COOKIE`, a host with no port uses it bare): + `path=/`, `SameSite=Lax`, one year, `httpOnly`. The port is in the name because a cookie is + shared by every port on a host and localStorage was per origin. +- **One writer:** `setActiveSiteAction` (`app/lib/activeSiteActions.ts`, `"use server"`), which + only shape-checks (`isStorableActiveSite`: a `SITE_ID_RE`-shaped id of ≤ 128 chars, or + `__all__`). Setting the cookie makes Next re-render the current page and its layouts, which + is how Dashboard and Channels re-scope; nothing calls `router.refresh()`. +- **One read:** `readActiveSite(param?, siteIds?)` (`app/lib/activeSiteServer.ts`, + `server-only`). The root layout calls it with no param, and Dashboard (`app/page.tsx`) and + Channels (`app/channels/page.tsx`) with `searchParams.site`. The precedence is + `resolveActiveSiteFrom(candidates, siteIds)`: the first candidate naming a configured site or + `__all__` wins, else `resolveActiveSite`'s default (the lone site, else all). The server passes + `[?site=, cookie]`; the picker passes `[its held choice, the path's site, ?site=, stored]`. +- **The root layout now reads a cookie, so every page renders per request** (the build lists + every page `ƒ`; route handlers were `ƒ` already, and `/icon.svg` stays static). The one page + whose mode changed is `/_not-found`. The picker's Suspense stays for the build's + missing-Suspense check. +- **`SiteScopeProvider`** (`app/components/SiteScopeProvider.tsx`, context + `useSiteScope()`) + holds `stored`, following the layout's value when it changes and not while a picker write is + in flight. `choose(value)` (the picker's changes) updates `stored` optimistically; the two + effects, visiting `/sites/<id>/…` and the one-time localStorage migration, only WRITE + (`record`), because a state change during hydration re-renders the picker before React + replays a pre-hydration change, and the replayed event then reads the reset value. +- **Other tabs.** A client-side navigation does not re-render the root layout, so a tab's + `stored` would stay what its layout last read while another tab changed the shared cookie. + Every successful write (`choose` and `record`) is therefore posted on + `new BroadcastChannel(ACTIVE_SITE_CHANNEL)` (`"archilyzer-active-site"`, per origin and so per + port), and the other tabs call `router.refresh()`. A channel instance does not receive its own + posts, so a tab does not refresh for its own write. Without `BroadcastChannel` a tab keeps its + value until its next full load. Passive refresh (`AutoRefresh`) also re-reads the layout, but + only when the pulse token moves. +- **A `?site=` link** governs its own request and is not stored. Choosing on such a page writes + the cookie, THEN `router.replace`s the URL without `site` (`withoutSiteParam`). On + `/sites/<id>/…` the picker writes the cookie, THEN pushes, so a page opened once the URL has + moved reads the new value. In both, the choice is held for the URL it was made on, so the + controlled select does not snap back while the navigation is in flight, and dropped on the first + render at another URL (else Back to that URL showed the old choice over the path). +- **A new channel starts checked on the active site** (review L5): `ChannelFormClient` resolves + `resolveActiveSiteFrom([?site=, useSiteScope().stored], siteIds)` on its first render, and + `SiteMembershipsSection` takes it into its INITIAL state (there was an effect), so the box is + checked in the server's HTML and a later refresh cannot re-check a box the user cleared. With + one site configured and nothing stored, that site starts checked. + **`BUILD_KINDS` is wrong in both directions** and was moved verbatim, with a comment saying so: six kinds (`build-index`, `build-stats`, `build-export`, `normalize-transcripts`, `archive-transcripts`, `archive-combined-transcripts`) — MISSING the three live-chat kinds the diff --git a/plans/release-15.md b/plans/release-15.md @@ -19,6 +19,7 @@ prompt carries its ruling, and this record carries what was built. Rules: | IG | `r15/index-hold` | The index build holds an unreachable channel instead of emptying it | `common/controller/buildIndex.ts` + new `buildIndex.test.ts`, `common/controller/buildStats.ts` (the hold's words move to a shared module), new `common/lib/channelMediaHold.ts`, `common/lib/envVars.ts`, `ENVIRONMENT.md`; records: `plans/{STATE,FACTS,stats-cache-key}.md` | | DS | `r15/drive-stall` | A stalled drive does not stop the editor answering | new `common/lib/storageHealth.ts`; `lib/{storageVolumes,channelMedia,channelMediaHold}.ts`, `controller/storageWatch.ts` and the gated callers; `/storage`, `/channels`, the videos pages; `UV_THREADPOOL_SIZE` (`editor/package.json`, `docker/entrypoint.sh`, `envVars.ts`) | | UT | `r15/umtool-trace` | umtool's build stops tracing the whole `umtool/` folder | per its prompt | +| SS | `r15/site-scope` | The editor's site picker paints the stored site at once: the selection is a cookie | `editor/app/lib/activeSite{,Server,Actions}.ts` + `activeSite.test.ts`, `editor/app/components/SiteScope{Provider,Select}.tsx`, `editor/app/layout.tsx`, the scope lines of `editor/app/page.tsx` and `editor/app/channels/page.tsx`, a comment in `editor/next.config.ts`, `editor/e2e/site-scope.spec.ts`; records: `plans/FACTS.md` | **Order:** IG → DS. DS adds a health gate inside `inspectChannelMedia`, which IG's hold calls through its public signature. UT is independent. The shared files are `editor/CHANGELOG.md`'s @@ -814,4 +815,277 @@ spin-up case stays the operator's question). Two new ones: | L11: the health pass's block sat inside the boot probe's comment, which still called itself the only thing an idle boot runs | Moved above it; the phrase dropped | `c367a3d7` | | (the implementer's note) four slow units past the budget on a disk whose counters show completions made the overdue refusal mark the location stalled | The same treatment as L6: the overdue refusal compares the counters with those taken when the oldest overdue call began; moved → refused, not marked; unchanged → marked | `9a12308e`, this commit | +### Slice SS, as shipped — the editor's site scope is a cookie (2026-09-29) + +Branch `r15/site-scope` off `main` `721ed0eb`, worktree +`~/Projects/plans-export-header-first-search` (block #4: editor 3401, test 3411, export 3410), one +Opus implementer. Scratch files `ss-*` in the job's `tmp`. The ruling: the active site is a +cookie, read once per request by the root layout and supplied through a provider, so the picker, +Dashboard and Channels render the stored site on the first paint. + +**What was wrong.** The sidebar's "Active site" picker (`SiteScopeSelect.tsx`) derived its value +from the URL on every render: a `?site=` param, else the `/sites/<id>` path, else "All sites". The +stored choice was `localStorage["activeSite"]`, read only in an effect after paint. That effect +`router.replace`d `?site=<id>` onto the URL, which re-rendered the picker with the right value. So +every navigation painted "All sites" first, and Dashboard and Channels (server components that +cannot read localStorage) rendered unscoped until the param arrived. + +- **The store** is a cookie, `archilyzer-active-site-<port>`: `path=/`, `SameSite=Lax`, one year, + `httpOnly`. The value is a site id or `__all__`. + - The name and its rules are in `app/lib/activeSite.ts`: `ACTIVE_SITE_COOKIE`, + `activeSiteCookieName(host)`, `isStorableActiveSite`, `ACTIVE_SITE_COOKIE_MAX_AGE`. + - The port is in the name because a cookie is shared by every port on a host, and localStorage + was per origin. The live editor and a worktree's editor on one machine keep one selection each, + as before. A host with no port (the default port, behind a proxy) uses the base name. +- **One writer:** `setActiveSiteAction` (`app/lib/activeSiteActions.ts`, `"use server"`). + - It checks shape only: a `SITE_ID_RE`-shaped id of at most 128 characters, or `__all__`. + Anything else writes nothing and returns false. Whether the id names a site is decided at every + read, so a site deleted later resolves as no selection. + - Setting a cookie in a server action makes Next re-render the current page and its layouts. + That re-render is how Dashboard and Channels re-scope, and how the layout hands the provider + the new value; nothing calls `router.refresh()`. +- **One read:** `readActiveSite(param?, siteIds?)` (`app/lib/activeSiteServer.ts`, `server-only`). + - The root layout calls it with no param (a layout has no `searchParams`). Dashboard + (`app/page.tsx`) and Channels (`app/channels/page.tsx`, the scope line only) call it with + `searchParams.site`. + - The precedence is one pure function, `resolveActiveSiteFrom(candidates, siteIds)`: the first + candidate naming a configured site or `__all__` wins, else `resolveActiveSite`'s default (the + lone site, else all sites). The server passes `[?site=, cookie]`. + - The layout reading a cookie makes every page render per request: the capped build lists every + page `ƒ`. The editor's pages were all `force-dynamic` already, apart from the not-found page; + route handlers were `ƒ` already, and `/icon.svg` stays static. +- **The provider** (`app/components/SiteScopeProvider.tsx`, new): a React context, mounted by the + root layout around `AppFrame` with `{ activeSite, fromCookie, siteIds }`, read with + `useSiteScope()`. + - It holds `stored`, which starts from the layout's value and follows it when that value changes. + It does not follow while one of the picker's writes is in flight, so the first of two quick + choices cannot paint over the second. + - `choose(value)` is for the picker's own changes. It updates `stored` optimistically, writes + through the action, and puts the previous value back if the write fails. + - **Two effects, and neither rewrites a URL:** + - Visiting `/sites/<id>/…` records that site, so Dashboard and Channels follow. It writes once + per arrival at that site's pages (a StrictMode or Fast Refresh re-run does not write again), + and not when the store already holds the site. + - The one-time **migration**: a visitor with `localStorage["activeSite"]` and no cookie has the + key copied into the cookie through the action, then removed once the write succeeds. With a + cookie, or on a site's page, a leftover key is removed without being read. This is the only + localStorage read. On that one visit the server had no cookie to render from, so the picker + paints the default until the write's re-render. + - **Both effects only write** (`record`); the value comes back through the server's re-render. + They run as the page hydrates. With `choose`'s optimistic state change there, the picker + re-rendered before React replayed a change made on the server-rendered select before + hydration. The re-render reset the select to its old value, the replayed change read that + value, and the two `/sites/<id>/…` cases of `site-scope.spec.ts` navigated nowhere + (`d394d0b6`; traced with a throwaway instrumented spec, not committed). + - **Other tabs** (`f645572c`, review M1). The cookie is shared by every tab of the origin, but a + tab's `stored` comes from its root layout, which a client-side navigation does not re-render. + A pick in another tab therefore left this tab's picker on the old site over pages that read + the new one, and a client-side visit to a site's page was skipped as already stored. + - Every successful write (`choose` and `record`) is posted on the BroadcastChannel + `ACTIVE_SITE_CHANNEL` (`"archilyzer-active-site"`). A channel is per origin, so each port + has its own, like the cookie's name. + - The other tabs answer with `router.refresh()`: the layout and the page re-render with the + cookie as it is now, and the tab's router cache is dropped. A channel instance does not + receive its own posts, so a tab does not refresh for its own write. + - A browser without `BroadcastChannel` keeps each tab's value until its next full load. +- **The picker** (`SiteScopeSelect.tsx`) renders from the context, the path and the URL. It has no + effect and reads no storage, and every accessible name is unchanged. + - What it shows, first match wins: the choice just made on this URL, the site the path names, a + valid `?site=`, then `stored`. + - On a site's pages it writes the cookie, **then** pushes the same tab of the other site (or + `/sites` for "All sites"). A page opened once the URL has moved therefore reads the new value. + - On a `?site=` link's page it writes, then `router.replace`s the URL without `site` + (`withoutSiteParam`), so the page and the picker agree. Elsewhere it only writes. + - In the first two cases the choice is held for the URL it was made on, so the controlled select + does not snap back while the navigation is in flight. The old picker snapped back on a site's + pages until the push landed. The hold is dropped on the first render at another URL + (`352ea8f7`); before that, Back to the page it was made on showed the old choice over the + path. +- **A new channel starts checked on the active site** (`5006c28f`, review L5, after DS merged). + - `ChannelFormClient` resolves it as the picker does off a site's pages: + `resolveActiveSiteFrom([?site=, useSiteScope().stored], siteIds)`, on its first render. It + used to read only `window.location.search`, in a mount effect, so `/channels/new` reached from + the editor's own links never pre-checked a site. + - `SiteMembershipsSection` takes that site into its initial state instead of an effect. The + server renders the box checked, so there is no unchecked first paint. A later refresh (from + the pulse, or a pick in another tab) cannot re-check a box the user has cleared; the effect + re-ran whenever `sites` came back as a new array. + - With one site configured and nothing stored, the active site is that site, so it starts + checked (see the decisions table). + +**`?site=` after this slice.** The editor's own navigation no longer appends it; nothing in +`editor/app` builds a `?site=` link. What still reads or carries one: + +| Where | What it does with `?site=` | +|---|---| +| Dashboard, Channels (`readActiveSite(site)`) | A valid one governs that request; it is not stored | +| The picker | Shows a valid one on that page; a choice there drops it | +| `next.config.ts` redirects | A retired `/charts`, `/aliases` or `/deploy` bookmark carrying `?site=<id>` lands on that site's tab (the comment now says the picker ignores the query there, `e684668c`) | +| `ChannelFormClient` (`/channels/new`) | A valid one is the site a new channel starts checked on, instead of the stored one (`5006c28f`) | +| `ChannelVolumeBar`'s chips | Keep whatever `?site=` the URL has when they add `?location=`; the comment now says the stored scope is a cookie (`5006c28f`) | +| e2e (`channel-groups`, `channel-priority`, `channels-rack-*`, `channel-site-membership`, `site-scope`, `navigation`) | Deep links; all still work | + +**Commits** + +| Commit | What | +|---|---| +| `9dc33d59` | `editor:` the cookie, its one writer and one read; `SiteScopeProvider`; the picker rewritten; the layout mounts the provider; Dashboard and Channels read through `readActiveSite`; `activeSite.test.ts` +8 (87 → 95). | +| `d394d0b6` | `editor:` the provider's two effects write the cookie without an optimistic state change (the hydration replay above); the visit is recorded once per arrival. | +| `0465d37b` | `editor(e2e):` `site-scope.spec.ts`: three `?site=` URL assertions inverted, two comments; three new cases. | +| `e684668c` | `editor:` `next.config.ts`'s retired-satellite comment. | +| `352ea8f7` | `editor:` the picker drops a held choice once the URL moves; the site-scope case for Back. | +| `49802455` | `plans:` this section, the slices row, FACTS ("Superseded by release 15 slice SS"), the editor changelog. | +| `f645572c` | `editor:` review M1: a successful write is broadcast, and the other tabs refresh. | +| `09559337` | `editor(e2e):` review M1 and L2: the two-tab case; `expectNoSiteParam()` after the settle waits. | +| `8104732b` | `editor:` review L1: the layout's comment says "every page". | +| `001c23d0` | `plans:` the review, its findings to their commits, L3, L4 and L6 under "Found and left", the rulings; FACTS (L1, other tabs); the changelog's other-tabs sentence. | +| `4b61be87` | `plans:` two lines of this section reflowed. | +| `07d9126e` | Merge `main` `ef4f1d7c` (DS and the rest). The one conflict, this file, kept `main`'s sections whole (IG, UT, DS) with SS's after them and its row after UT's; FACTS and the changelog merged cleanly. | +| `5006c28f` | `editor:` review L5: a new channel starts checked on the active site, from the provider, as initial state; the stale `?site=` comments in `ChannelFormClient`, `ChannelForm`, `SiteMembershipsSection` and `ChannelVolumeBar`. | +| `677d1668` | `editor(e2e):` the new-channel case. | +| this commit | `plans:` L5 and the merge in this section; FACTS; the report. | + +**Tests** + +- **Unit** (`app/lib/activeSite.test.ts`, +8): the cookie name per port (IPv4, IPv6, no port, no + host); what may be stored (a header-injection string, `_homepage`, 129 characters and non-strings + refused; 128 accepted); the precedence (a param beats the cookie, a param naming no site falls + through to it, `__all__` is a choice, the default); `withoutSiteParam` keeps every other param. +- **e2e** (`editor/e2e/site-scope.spec.ts`). The contract cases keep their names and labels. The + seeding removal changed three assertions and two comments, and nothing else: + - in "selecting a site scopes the channels list and persists", two `toHaveURL(/site=alpha/)` + became `toHaveURL(/\/channels$/)`; + - in "charts is a site's tab", `/site=beta/` likewise; + - the comments now say "cookie" where they said "localStorage"; + - after review L2 (`09559337`), each of the three is followed by `expectNoSiteParam()`. It waits + for hydration and the 750 ms settle, then checks the URL has no `site` param, with no retry. + `toHaveURL` alone passes on its first poll, before a `replace` from an effect could land. The + first-paint case makes the same check after each of its settles. +- **New cases:** + +| Case | What it pins | On the pre-change code | +|---|---|---| +| a stored site is the picker's first paint on every page, never All sites | alpha stored through the picker. On `/`, `/channels`, `/jobs`, `/settings`, `/sites/alpha` and `/`, the value is read ONCE, with no retry, right after `domcontentloaded`. After each page hydrates and settles for 750 ms, an init script's record of every value the select ever had (at every DOM mutation and every animation frame) is exactly `["alpha"]`. The same holds across client-side navigations through the sidebar. `/sites/beta` records beta, and the next `/` paints beta. No console message or page error matching `/hydrat/i` | fails at the first read: `/: first paint` expected `"alpha"`, received `"__all__"`. With that read removed, it fails at the record: `["__all__", "alpha"]` | +| a `?site=` link scopes its own page and is not stored; a choice there drops it | `/channels?site=beta` paints beta and lists beta's channel; the cookie stays alpha, and the next `/channels` is alpha; choosing "All sites" on `/channels?site=beta` leaves `/channels` with both channels and the cookie `__all__` | not run (the cookie assertions cannot hold) | +| a choice the old picker kept in localStorage moves to the cookie once | the key seeded from a route that mounts no app; `/channels` then shows beta scoped, the cookie is beta and the key is gone; the next `/` paints beta; a leftover key with a cookie is removed and never read | not run | +| Back to a site's page shows that site, not the choice made there | on `/sites/alpha/charts`, choosing beta goes to `/sites/beta/charts`; Back shows alpha | not run on the pre-change code; with `0465d37b`'s picker (the fix line removed) it received `"beta"` | +| a new channel starts checked on the stored site, or on a `?site=` link's (`677d1668`) | alpha stored: `/channels/new` has Alpha checked and Beta not, read with no retry after `domcontentloaded`; `/channels/new?site=beta` has Beta checked and not Alpha as well; with Alpha cleared by hand, a pick of beta in another tab refreshes the page and checks neither | on the pre-L5 form (`07d9126e`'s `ChannelFormClient` and `SiteMembershipsSection`): fails at the first read, `stored site, first paint` expected `true`, received `false` | +| a site picked in another tab reaches this one (`09559337`) | two pages in one context, passive refresh off (`autoRefreshIntervalSeconds: 0`), so only the broadcast can move tab A. Tab A stores alpha and sits on `/settings`; tab B picks beta; tab A's picker becomes beta with no reload, and its next Channels page (a sidebar click) is beta's. A client-side visit from tab A to `/sites/alpha` records alpha, and tab B's picker follows | with the broadcast's `router.refresh()` removed, tab A stays `"alpha"`. With passive refresh left on, tab A had moved anyway, through a pulse-driven tree refresh, and only tab B's check caught the missing broadcast; hence the setting | + +The spec's comment says why: Playwright's auto-retrying `toHaveValue` cannot see a one-paint flash; +it polls until the value is right and passes. + +#### Gates (logs `$T/ss-*.log`) + +- **tsc** (all workspaces) was clean before every commit: 69 s, 38 s, 102 s and 28 s at the four + full runs, and 60 s for the review fixes. The editor-only runs for `0465d37b` and `e684668c` + took about 13 s. A killed dev server left a truncated `.next/dev/types/*.ts` once; that + directory is generated, and it was removed after each stopped run. +- **Unit:** + + | Suite | Result | + |---|---| + | common | 2,229/2,229, 44 s | + | editor unit | **95/95** (87 + 8), and again after the review fixes | + | `test:scripts` | 191 passed, 1 skipped (192) | + | mcp | 271/271 | + +- **Docs:** `docs env --check`, `docs files --check` and `settings example --check` all exit **0**. +- **Build:** the editor's `next build`, with the primary's `transcripts/` linked in and capped at + 5 GB with no swap: **33 s, max RSS 1,628 MB**, exit 0. Every page is listed `ƒ`. The link was + removed after the build, and nothing ran through it. After the merge of `main` and L5, at + `677d1668`: **36 s, max RSS 1,617 MB**, exit 0; only `/icon.svg` is `○`. +- **After the merge of `main` and L5** (at `677d1668`): tsc (all workspaces) clean, 35 s; common + **2,301/2,301** (`main`'s count), 51 s; editor unit **95/95**. +- **e2e** (editor, detached and queued): + - `site-scope.spec.ts` alone: + - at `9dc33d59`: 8 passed, 2 failed (the two `/sites/<id>/…` cases, fixed by `d394d0b6`); + - at `d394d0b6`: 9 passed, 1 failed, 3.4 min. The failure was "creating a channel under a + site": the create never navigated within 10 s, with the Next dev indicator on "Rendering…" + and a load average of 26. It passed in the run before, and in every run after; + - at `352ea8f7` (with the Back case): **11 passed, 0 failed, 53 s**; + - after the review fixes (the code of `8104732b`, whose layout change is a comment): **12 + passed, 0 failed, 1.6 min**, after about 2 min in the queue. + - **After the merge of `main` and L5** (at `677d1668`; `$T/ss-specs-l5.txt`: `site-scope` plus + every spec that visits `/channels/new`: `channel-site-membership`, `channels`, + `new-channel-onboarding`, `social-channel`, `pipeline`, `queues`, `dashboard`): **56 passed, + 0 failed, 6.0 min**. The full suite was not rerun, as the parent directed. + - The pre-change checks above: the old code swapped in once, then restored. The Back case was + run once with the fix line removed, then restored. So was the two-tab case, with the + broadcast's `router.refresh()` replaced by a no-op, twice: with passive refresh on, then off. + - **The spec list** (at `e684668c`; `$T/ss-specs.txt`: `site-scope` plus every spec that visits + `/` or `/channels` or uses `?site=`, 28 specs, 169 tests): + - a first run was spoiled by this implementer. `next.config.ts` was edited mid-run, and the dev + server restarted and served 404s. It was stopped, and its orphaned servers were killed. + Before the edit, one case had failed: `backfill.spec.ts:462`, whose `uncheck` of "Enable + auto-backfill" did not change the box. That spec passed in the clean run; + - the clean run: **157 passed, 0 failed, 12 skipped** (the rack screenshot audit, which needs + `E2E_RACK_SHOTS=1`), **8.2 min**. + - **The full editor suite** at `352ea8f7`: **658 passed, 0 failed, 12 skipped** (the same rack + audit), **39.2 min**, after less than a minute in the queue. An earlier full run was stopped + two minutes in, to land `352ea8f7` first. It was not rerun for the review fixes, as the parent + directed: they touch the provider and `site-scope.spec.ts` only. + +#### Found and left + +- **`export/app/(workspace)/WorkspaceView.tsx:60-73` (`splitOn`)** has the same class of one-paint + flash: a localStorage value restored in an effect after the first paint. `export/**` is not this + slice's. +- **A change on a site's pages waits one server round trip before it navigates** (the cookie write + comes first), and a change there right after landing also waits for the visit's own write, + because Next runs server actions one at a time. The select shows the choice at once. +- **A change made before hydration** reaches the picker through React's replay, as before. It + would be lost again if anything set state in the provider or picker during hydration; the + comment in `SiteScopeProvider.tsx` says so. +- **`app/sites/[siteId]/layout.tsx:11`** still describes the retired satellites as reading + `?site=`. That is history, and it is accurate. +- **Two server renders per change on a site's page, or on a `?site=` page** (review L3). The + write's re-render draws the page being left, then the push or replace draws the next one. This + is the cost of the accepted round trip. +- **A narrow migration race** (review L4). On the first visit after the update, a visitor may + have the old key and no cookie, and change the server-rendered select before hydration. If + React replays that change before the migration's write is queued, the old key's value is + written second and wins. The picker then shows that value, and it agrees with the cookie. +- **A stored `__all__` with one site left** (review L6, not a regression). The server resolves + "all sites", but the picker has no "All sites" option with one site, so the select shows the + lone site while Dashboard and Channels show the whole pool. `main` did the same through the + seeded `?site=__all__`. +- **A tab restored from the back-forward cache** is not refreshed; the review offered a + `pageshow` handler as optional, and it was not added. + +#### Decisions the operator could overturn + +| What I assumed | The alternative | +|---|---| +| The cookie's name carries the port, so each editor on one host keeps its own selection, as localStorage did. **Ruled at review: it stays.** | One host-wide name: selecting in a worktree's editor would change the live editor's selection, and a site id the other has not got resolves as "All sites" there | +| `httpOnly`: the page gets the value through the layout, never from `document.cookie` | Readable from script; nothing needs it | +| The action checks shape only, and existence is decided at read | Refuse ids that name no site at write time; a site deleted later still needs the read-time check | +| A `?site=` naming no site falls through to the cookie. The old picker ended there too, by replacing the param with the stored value after the first paint. **Ruled at review: it stays.** | Fall to the default (the lone site, else "All sites") for that page | +| Choosing on a `?site=` link's page drops the param and stores the choice. **Ruled at review: it stays.** | Keep the param and store nothing, as a link's page is "just that page"; the picker and the page would then disagree | +| On a site's pages, the cookie is written before the push. **Ruled at review: the round trip is accepted.** | Push first and write after: faster, but a page opened right after the URL moves could read the old value, and a navigation started while an action is pending discards the action's re-render | +| The migration writes without an optimistic update, so its one flash lasts until the write's re-render | Update at once: a shorter flash, but a state change during hydration (see above) | +| A new channel starts checked on the active site as the picker resolves it, so an editor with one site and nothing stored starts it checked on that site | Only a stored choice or a `?site=` link; the lone site would then need a click | +| The new-channel pre-check is the form's initial state, so a pick made in another tab while the form is open does not change its boxes | Follow the active site while the form is untouched | + +#### Review + +**Verdict: SHIP AFTER FIXES** (`ss-review.md` in the job's scratch). There was no High. The review +held that single-tab first paint, hydration, the cookie, the action and the migration are right. +It reproduced M1 with a two-tab probe under the queue lock. + +| Finding | Where | +|---|---| +| M1: a pick in another tab left this tab's picker stale, and a client-side visit to a site's page unrecorded | `f645572c` (the broadcast and refresh), `09559337` (the two-tab case) | +| L1: FACTS said every route renders per request; it is every page | this commit (FACTS), `8104732b` (the layout's comment); this section said it already | +| L2: the inverted URL assertions pass on their first poll | `09559337`: `expectNoSiteParam()` | +| L3: two server renders per change on a site's page or a `?site=` page | "Found and left" | +| L4: a narrow migration race | "Found and left" | +| L5: stale `?site=` comments, and the `/channels/new` pre-check | After DS reached `main` (merged at `07d9126e`): `5006c28f`, `677d1668` | +| L6: a stored `__all__` with one site left | "Found and left" (pre-existing) | +| Questions: the port in the name, `?site=` naming no site, a choice on a `?site=` page, the round trip | Ruled: all four stay (see the decisions table) | + +**What runs which code, for the rollout.** The picker, the provider and the pages are in the +editor's built bundle, so all of it takes effect only after the editor is rebuilt and restarted. +After that, each browser's first visit migrates its localStorage selection once. + ## Rollout