commit 330f3134069da858cc9a89762b2d6474ab82cbb9
parent 750c5cd9512b4ac65209de21ecdb4274770b4eee
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 01:52:06 -0400
sites: JobLane survives Strict Mode — under next dev a lane dropped every update of its one launch and sat on "Starting…" forever
The launch effect gated its updates on a flag local to it, set by its
cleanup; Strict Mode's mount → cleanup → mount set the flag and the second
run returned early on startedRef, so the job ran and the lane never showed
it. Liveness is now a ref kept by its own effect, declared first. Production
mounts once and is unchanged. No spec had ever clicked a JobLane button
(batch, specific sites, hub), which is how it went unseen.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
1 file changed, 21 insertions(+), 8 deletions(-)
diff --git a/editor/app/sites/components/JobLane.tsx b/editor/app/sites/components/JobLane.tsx
@@ -44,12 +44,26 @@ export function JobLane({ title, subtitle, trigger }: Props) {
const [cancelling, setCancelling] = useState(false);
const preRef = useRef<HTMLPreElement | null>(null);
const startedRef = useRef(false);
+ // Whether the lane is mounted, kept by its OWN effect. It used to be a flag
+ // local to the launch effect below, set by that effect's cleanup — and
+ // Strict Mode (on under `next dev`) runs mount → cleanup → mount: the
+ // cleanup set the flag, the second run returned early on startedRef, and
+ // every update of the one launch there is was dropped. The job ran; the lane
+ // sat on "Starting…" forever. Declared first, so on the remount it is true
+ // again before anything reads it. Production mounts once and never saw it.
+ const aliveRef = useRef(false);
+ useEffect(() => {
+ aliveRef.current = true;
+ return () => {
+ aliveRef.current = false;
+ };
+ }, []);
useEffect(() => {
// Strict-mode mounts effects twice in dev; guard so the job launches once.
if (startedRef.current) return;
startedRef.current = true;
- let cancelled = false;
+ const unmounted = () => !aliveRef.current;
let stopped = false;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
@@ -64,7 +78,7 @@ export function JobLane({ title, subtitle, trigger }: Props) {
status: string;
queuePosition?: number;
};
- if (!cancelled) {
+ if (!unmounted()) {
if (data.status === "queued") {
setStatus("queued");
setQueuePos(
@@ -81,7 +95,7 @@ export function JobLane({ title, subtitle, trigger }: Props) {
} catch {
/* transient; keep polling */
}
- if (!cancelled && !stopped) pollTimer = setTimeout(() => poll(id), 1000);
+ if (!unmounted() && !stopped) pollTimer = setTimeout(() => poll(id), 1000);
}
(async () => {
@@ -89,13 +103,13 @@ export function JobLane({ title, subtitle, trigger }: Props) {
try {
result = await trigger();
} catch (e) {
- if (!cancelled) {
+ if (!unmounted()) {
setStatus("error");
setError((e as Error).message);
}
return;
}
- if (cancelled) return;
+ if (unmounted()) return;
if (!result.ok) {
setStatus("error");
setError(result.error);
@@ -110,7 +124,7 @@ export function JobLane({ title, subtitle, trigger }: Props) {
while (true) {
const { value, done } = await reader.read();
if (done) break;
- if (value && !cancelled) setLog((prev) => prev + value);
+ if (value && !unmounted()) setLog((prev) => prev + value);
}
} catch {
/* stream tear-down */
@@ -118,7 +132,7 @@ export function JobLane({ title, subtitle, trigger }: Props) {
const term = await result.done;
stopped = true;
if (pollTimer) clearTimeout(pollTimer);
- if (!cancelled) {
+ if (!unmounted()) {
setQueuePos(null);
setStatus(
term.status === "done"
@@ -131,7 +145,6 @@ export function JobLane({ title, subtitle, trigger }: Props) {
})();
return () => {
- cancelled = true;
if (pollTimer) clearTimeout(pollTimer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps