commit 63199cc69bf204e1a4b4e8ae8bde9fabc07d4e1e
parent bafc953c31e8555d3182c764418f8d4a2ae5577a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 12:47:16 -0400
sites: /sites re-renders when a homepage build lane ends, so "built <when>" is current
JobLane gets `onSettled(outcome)`: called once when the lane reaches its
terminal state (done / failed / cancelled, or "error" when the trigger
refused), through a ref so the one-shot launch effect calls the latest
prop, and guarded so it fires at most once under Strict Mode's
mount-cleanup-mount (release 11 slice O4's JobLane fix). Not called for a
lane already unmounted by a newer launch.
HomepageBuildButtons calls router.refresh() when a lane that BUILDS
(Build homepage, Build & deploy homepage) ends with a job outcome — every
outcome, not only done: a failed or cancelled build may already have
rewritten or emptied homepage/out, and the line says what Deploy homepage
would ship. As StreamActionLog refreshes after any run that started.
e2e sites-homepage.spec:
- new: a build lane cancelled from OUTSIDE the page (the harness reset;
the lane's own Cancel is a revalidating server action that re-renders by
itself), passive refresh off — a site written after the page loaded
appears without a reload. Nothing builds: both queues held, as before.
- the Build homepage test also polls the job's sidecar to `cancelled`
(the previous commit's fix, seen from the UI path).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 113 insertions(+), 25 deletions(-)
diff --git a/editor/app/sites/components/HomepageBuildButtons.tsx b/editor/app/sites/components/HomepageBuildButtons.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useRouter } from "next/navigation";
import {
MAX_PREVIEW_BRANCH,
previewAliasUrl,
@@ -13,7 +14,7 @@ import {
deployHomepageAction,
} from "../lib/homepageDeployActions";
import { useHydrated } from "../../operations/components/useOperationsStatus";
-import { JobLane } from "./JobLane";
+import { JobLane, type LaneOutcome } from "./JobLane";
type Lane = {
kind: "build" | "build-deploy" | "deploy";
@@ -55,6 +56,7 @@ export function HomepageBuildButtons({ project, builtAt }: Props) {
const [lane, setLane] = useState<Lane | null>(null);
const [run, setRun] = useState(0);
const hydrated = useHydrated();
+ const router = useRouter();
const branch = preview.trim();
const problem = branch ? previewBranchProblem(branch) : null;
@@ -67,6 +69,17 @@ export function HomepageBuildButtons({ project, builtAt }: Props) {
setLane({ kind, key: next, preview: branch || undefined });
}
+ // "built <when>" is read from homepage/out when /sites renders, so a lane
+ // that BUILT has to re-render the page for the line to move (release 13
+ // slice W1). Every outcome of a job, not only "done": a failed or cancelled
+ // build may already have rewritten or emptied homepage/out, and the line —
+ // which is what Deploy homepage would ship — must say so. "error" started no
+ // job, and a deploy writes nothing the page reads. StreamActionLog refreshes
+ // after any run that started, for the same reason.
+ function settled(l: Lane, outcome: LaneOutcome) {
+ if (l.kind !== "deploy" && outcome !== "error") router.refresh();
+ }
+
function trigger(l: Lane) {
const opts = l.preview ? { previewBranch: l.preview } : undefined;
return l.kind === "build"
@@ -180,6 +193,7 @@ export function HomepageBuildButtons({ project, builtAt }: Props) {
: `homepage/out · ${project}${lane.preview ? ` (preview ${lane.preview})` : " (production)"}`
}
trigger={() => trigger(lane)}
+ onSettled={(outcome) => settled(lane, outcome)}
/>
)}
</div>
diff --git a/editor/app/sites/components/JobLane.tsx b/editor/app/sites/components/JobLane.tsx
@@ -13,11 +13,19 @@ type LaneStatus =
| "cancelled"
| "error";
+// How a lane ENDS: its job's terminal status, or "error" when the trigger
+// refused (or threw) before any job existed.
+export type LaneOutcome = "done" | "failed" | "cancelled" | "error";
+
type Props = {
title: string;
subtitle: string;
// Launched once on mount. Returns the managed-job descriptor (or an error).
trigger: () => Promise<StreamActionResult>;
+ // Called ONCE, when the lane reaches its terminal state, with that state —
+ // e.g. to refresh a server-rendered "built <when>" line after a build. Not
+ // called when the lane is unmounted first (another launch replaced it).
+ onSettled?: (outcome: LaneOutcome) => void;
};
const CHIP: Record<LaneStatus, { label: string; cls: string }> = {
@@ -35,7 +43,7 @@ const CHIP: Record<LaneStatus, { label: string; cls: string }> = {
// the terminal status. A light poll surfaces queued/running + queue position
// before the job reaches a terminal state. Mirrors StreamActionLog's plumbing in
// miniature so several lanes can run at once on the batch panel.
-export function JobLane({ title, subtitle, trigger }: Props) {
+export function JobLane({ title, subtitle, trigger, onSettled }: Props) {
const [status, setStatus] = useState<LaneStatus>("starting");
const [log, setLog] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -58,6 +66,16 @@ export function JobLane({ title, subtitle, trigger }: Props) {
aliveRef.current = false;
};
}, []);
+ // The latest onSettled, read when the lane ends: the launch effect below runs
+ // once, so a prop captured by its closure would be the first render's. And
+ // settle() fires it at most once — the launch is one-shot (startedRef), but
+ // "once" is this prop's contract, so it is guarded here rather than implied
+ // by the effect's shape (Strict Mode's mount → cleanup → mount included).
+ const onSettledRef = useRef(onSettled);
+ useEffect(() => {
+ onSettledRef.current = onSettled;
+ });
+ const settledRef = useRef(false);
useEffect(() => {
// Strict-mode mounts effects twice in dev; guard so the job launches once.
@@ -66,6 +84,14 @@ export function JobLane({ title, subtitle, trigger }: Props) {
const unmounted = () => !aliveRef.current;
let stopped = false;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
+ // The lane's terminal state: its chip, then the caller (once).
+ function settle(outcome: LaneOutcome) {
+ if (unmounted()) return;
+ setStatus(outcome);
+ if (settledRef.current) return;
+ settledRef.current = true;
+ onSettledRef.current?.(outcome);
+ }
async function poll(id: string) {
try {
@@ -103,16 +129,14 @@ export function JobLane({ title, subtitle, trigger }: Props) {
try {
result = await trigger();
} catch (e) {
- if (!unmounted()) {
- setStatus("error");
- setError((e as Error).message);
- }
+ if (!unmounted()) setError((e as Error).message);
+ settle("error");
return;
}
if (unmounted()) return;
if (!result.ok) {
- setStatus("error");
setError(result.error);
+ settle("error");
return;
}
setJobId(result.jobId);
@@ -132,16 +156,14 @@ export function JobLane({ title, subtitle, trigger }: Props) {
const term = await result.done;
stopped = true;
if (pollTimer) clearTimeout(pollTimer);
- if (!unmounted()) {
- setQueuePos(null);
- setStatus(
- term.status === "done"
- ? "done"
- : term.status === "failed"
- ? "failed"
- : "cancelled",
- );
- }
+ if (!unmounted()) setQueuePos(null);
+ settle(
+ term.status === "done"
+ ? "done"
+ : term.status === "failed"
+ ? "failed"
+ : "cancelled",
+ );
})();
return () => {
diff --git a/editor/e2e/sites-homepage.spec.ts b/editor/e2e/sites-homepage.spec.ts
@@ -7,21 +7,28 @@
// from a spec would be a real one. So:
// - no spec clicks Deploy homepage, and none ticks Deploy after build and
// then clicks Build homepage;
-// - the one spec that clicks Build homepage first holds BOTH the `build` and
+// - the two specs that click Build homepage first hold BOTH the `build` and
// the `deploy` queue with fabricated jobs (/api/test/stuck-job, never
-// released here), so the job it starts only ever QUEUES — the
+// released here), so the job each starts only ever QUEUES — the
// build-homepage it expects on `build`, and equally a build-deploy-homepage
// on `deploy` if a regression turned the click into a build-and-deploy —
-// and it is cancelled from its own lane while still queued. Its start
-// function never runs: no log file, no child, nothing written to
-// homepage/public or homepage/out. If the spec fails before its Cancel, the
-// next resetData cancels jobs newest first, so the queued job is removed
-// before a holder's slot is freed.
+// and it is cancelled while still queued: from its own lane, or (the
+// re-render spec) by the harness reset. Its start function never runs: no
+// log file, no child, nothing written to homepage/public or homepage/out.
+// If a spec fails before its cancel, the next resetData cancels jobs newest
+// first, so the queued job is removed before a holder's slot is freed.
import { readdir } from "node:fs/promises";
import { test, expect, type Page } from "@playwright/test";
import { baseUrl } from "./baseUrl";
-import { pathExists, readJson, resetData, resolvePath } from "./helpers";
+import {
+ pathExists,
+ readJson,
+ resetData,
+ resolvePath,
+ writeSettings,
+ writeSite,
+} from "./helpers";
const group = (page: Page) =>
page.getByRole("group", { name: "Homepage build" });
@@ -205,4 +212,49 @@ test("Build homepage starts a build-homepage job on the build queue (held there,
expect(log.status).toBe("cancelled");
expect(log.content).toBe("");
expect(await pathExists(`test-transcripts/.jobs/${job.id}.log`)).toBe(false);
+ // …and its sidecar says so too (release 13 slice W1). A job cancelled while
+ // queued used to keep the "queued" its enqueue wrote: the cancel path never
+ // rewrote the meta, so once the registry forgot the job (a restart, or 100
+ // newer ones) /jobs read it back as still queued.
+ await expect
+ .poll(async () => (await metasOfKind("build-homepage"))[0]?.status)
+ .toBe("cancelled");
+});
+
+test("a build lane that ends re-renders /sites, so the \"built <when>\" line is current", async ({
+ page,
+ request,
+}) => {
+ // The line reads homepage/out when /sites renders, and nothing here may
+ // build (see the header) — so the proof is that the page RE-RENDERS when the
+ // lane ends: a site written to disk after the page loaded appears without a
+ // reload. Passive refresh is off (it would re-render on the job's status
+ // change by itself), and the job is cancelled from OUTSIDE the page, through
+ // the harness reset, because the lane's own Cancel is a server action that
+ // revalidates, and its response re-renders the page on its own.
+ await writeSettings({ autoRefreshIntervalSeconds: 0 });
+ for (const queue of ["build", "deploy"]) {
+ const hold = await request.get(
+ `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent(queue)}`,
+ );
+ expect(hold.ok(), queue).toBe(true);
+ }
+
+ await openSites(page);
+ await buildButton(page).click();
+ await expect(group(page).getByText(/^Queued/)).toBeVisible({ timeout: 15_000 });
+
+ await writeSite("refresh-probe");
+ const probe = page.locator('a[href="/sites/refresh-probe"]');
+ await expect(probe).toHaveCount(0);
+
+ // Cancels every live job, newest first: the queued build before either
+ // holder's slot is freed, so nothing is promoted and nothing builds.
+ const reset = await request.get(`${baseUrl}/api/test/invalidate-cache`);
+ expect(reset.ok()).toBe(true);
+
+ await expect(group(page).getByText("Cancelled", { exact: true })).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(probe).toBeVisible({ timeout: 15_000 });
});