Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions frontend/src/components/RelaunchBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RotateCw } from "lucide-react";
import { useState } from "react";

import { useRelaunch } from "@/hooks/useRelaunch";
import { useReloadAfterRelaunch } from "@/hooks/useRelaunch";

/**
* "You updated; the running Condor is still the old one."
Expand All @@ -10,7 +10,7 @@ import { useRelaunch } from "@/hooks/useRelaunch";
* the top of its own process tree, and re-execing races whatever started it
* into a second copy on the same port (`condor/updates/run.py`). What that
* trade buys is safety; what it costs is a window where the dashboard bundle in
* the browser is newer than the API answering it. This strip is what makes that
* an open tab is older than the updated files on disk. This strip makes that
* window legible instead of just weird, so it rides above every page rather
* than living in Settings where only the person who ran the update would see it.
*
Expand All @@ -19,7 +19,7 @@ import { useRelaunch } from "@/hooks/useRelaunch";
* nobody is told about any more.
*/
export function RelaunchBanner() {
const { data } = useRelaunch();
const { data } = useReloadAfterRelaunch();
const [showHow, setShowHow] = useState(false);

if (!data?.required) return null;
Expand Down
124 changes: 124 additions & 0 deletions frontend/src/hooks/useRelaunch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/** @vitest-environment jsdom */

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { RelaunchResponse } from "@/lib/api";

const getRelaunch = vi.fn<() => Promise<RelaunchResponse>>();
vi.mock("@/lib/api", () => ({ api: { getRelaunch: () => getRelaunch() } }));

import { RELAUNCH_KEY, useReloadAfterRelaunch } from "./useRelaunch";

declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean;
}

function Harness() {
const query = useReloadAfterRelaunch();
useEffect(() => {
if (query.isSuccess) observed.push(query.data.required);
}, [query.data, query.isSuccess]);
return null;
}

let observed: boolean[];
let container: HTMLDivElement;
let root: Root;
let client: QueryClient;
let reload: ReturnType<typeof vi.fn>;

async function mount() {
await act(async () => {
root.render(
<QueryClientProvider client={client}>
<Harness />
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}

async function poll() {
await act(async () => {
await client.invalidateQueries({ queryKey: RELAUNCH_KEY });
await new Promise((resolve) => setTimeout(resolve, 0));
});
}

beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
observed = [];
getRelaunch.mockReset();
reload = vi.fn();
vi.stubGlobal(
"window",
Object.create(window, { location: { value: { reload } } }),
);
client = new QueryClient();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
client.clear();
container.remove();
vi.unstubAllGlobals();
});

describe("useReloadAfterRelaunch", () => {
it("reloads exactly once after a successful required true to false transition", async () => {
getRelaunch
.mockResolvedValueOnce({ required: true })
.mockResolvedValue({ required: false });

await mount();
expect(observed).toContain(true);
expect(reload).not.toHaveBeenCalled();
await poll();
expect(getRelaunch).toHaveBeenCalledTimes(2);
expect(client.getQueryData(RELAUNCH_KEY)).toEqual({ required: false });
expect(reload).toHaveBeenCalledTimes(1);

await poll();
await poll();
expect(reload).toHaveBeenCalledTimes(1);
});

it("never reloads a session that only sees required false", async () => {
getRelaunch.mockResolvedValue({ required: false });

await mount();
await poll();
expect(reload).not.toHaveBeenCalled();
});

it("does not mistake a failed poll for a completed relaunch", async () => {
getRelaunch
.mockResolvedValueOnce({ required: true })
.mockRejectedValueOnce(new Error("server offline"))
.mockResolvedValueOnce({ required: false });

await mount();
expect(observed).toContain(true);
await act(async () => {
void client.invalidateQueries({ queryKey: RELAUNCH_KEY });
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(getRelaunch).toHaveBeenCalledTimes(2);
expect(reload).not.toHaveBeenCalled();

await act(async () => {
await client.refetchQueries({ queryKey: RELAUNCH_KEY });
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(getRelaunch).toHaveBeenCalledTimes(3);
expect(reload).toHaveBeenCalledTimes(1);
});
});
20 changes: 20 additions & 0 deletions frontend/src/hooks/useRelaunch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";

import { api } from "@/lib/api";

Expand Down Expand Up @@ -29,3 +30,22 @@ export function useRelaunch() {
retry: true,
});
}

/** Refresh an open tab once the relaunched server answers with the new build. */
export function useReloadAfterRelaunch() {
const query = useRelaunch();
const sawRequired = useRef(false);
const reloaded = useRef(false);

useEffect(() => {
if (!query.isSuccess) return;
if (query.data.required) {
sawRequired.current = true;
} else if (sawRequired.current && !reloaded.current) {
reloaded.current = true;
window.location.reload();
Comment thread
david-hummingbot marked this conversation as resolved.
}
}, [query.data, query.isSuccess]);

return query;
}
Loading