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
17 changes: 9 additions & 8 deletions apps/loopover-ui/eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@ export default tseslint.config(
},
rules: {
...reactHooks.configs.recommended.rules,
// #8608: eslint-plugin-react-hooks 5 -> 7 (forced by the eslint 10 security chain, GHSA-mh99-v99m-4gvg)
// introduced these React-Compiler-era rules, which flag 24 pre-existing files. Fixing setState-in-effect
// patterns is real UI refactoring with behaviour risk -- not something to smuggle into a dependency
// bump -- so exactly the NEW rules are demoted to warn, keeping every rule that existed in v5 at error.
// Follow-up (promote back to error file-by-file): see the issue this comment cites.
// #8608 demoted the four React-Compiler-era rules that arrived with eslint-plugin-react-hooks 5 -> 7
// because they flagged 24 pre-existing files. #9588 fixed them: purity, refs and static-components are
// now clean and back at error, where the recommended preset puts them.
//
// set-state-in-effect stays at warn for ONE remaining site: docs-toc reads its headings out of the
// rendered DOM. Sourcing them from the compiled MDX `toc` instead is the real fix, but the component
// lives in the docs LAYOUT while that data lives in the child route -- and it also serves docs pages
// that are not MDX at all and so have no compiled toc. That is its own change, tracked in #9872;
// every other call site in this app is already clean.
"react-hooks/set-state-in-effect": "warn",
"react-hooks/purity": "warn",
"react-hooks/refs": "warn",
"react-hooks/static-components": "warn",
"no-restricted-imports": [
"error",
{
Expand Down
26 changes: 15 additions & 11 deletions apps/loopover-ui/src/components/site/animated-terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,26 +63,30 @@ export function AnimatedTerminal({
}) {
const reduce = useReducedMotion();
const [sceneIdx, setSceneIdx] = useState(0);
const [typed, setTyped] = useState("");
const [showOutput, setShowOutput] = useState(false);
// The typing progress belongs to ONE scene, so it is stored under that scene's key (#9588). Advancing
// the scene therefore resets the animation by derivation -- progress recorded for a previous scene can
// never be read -- instead of two setState calls at the top of the effect. Reduced motion needs no
// state at all: the finished frame is the pure value.
const [progress, setProgress] = useState({ scene: 0, typed: "", showOutput: false });

const scene = scenes[sceneIdx];
const current =
progress.scene === sceneIdx ? progress : { scene: sceneIdx, typed: "", showOutput: false };
const typed = reduce ? scene.prompt : current.typed;
const showOutput = reduce ? true : current.showOutput;

useEffect(() => {
if (reduce) {
setTyped(scene.prompt);
setShowOutput(true);
return;
}
setTyped("");
setShowOutput(false);
if (reduce) return;
let i = 0;
const type = window.setInterval(() => {
i += 1;
setTyped(scene.prompt.slice(0, i));
setProgress({ scene: sceneIdx, typed: scene.prompt.slice(0, i), showOutput: false });
if (i >= scene.prompt.length) {
window.clearInterval(type);
window.setTimeout(() => setShowOutput(true), 220);
window.setTimeout(
() => setProgress({ scene: sceneIdx, typed: scene.prompt, showOutput: true }),
220,
);
}
}, TYPE_SPEED);
return () => window.clearInterval(type);
Expand Down
17 changes: 13 additions & 4 deletions apps/loopover-ui/src/components/site/api-progress-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,20 @@ export function ApiProgressBar() {
const active = inFlight > 0 || status === "loading";
const [visible, setVisible] = useState(false);

// Becoming active shows the bar IMMEDIATELY, adjusted during render rather than from an effect (#9588):
// React's documented pattern for reacting to a changed input, and it renders the bar in the same commit
// that saw the change instead of a frame later.
const [wasActive, setWasActive] = useState(active);
if (wasActive !== active) {
setWasActive(active);
if (active) setVisible(true);
}

// Only the trailing fade-out is a timer, and its setState runs in the timer callback -- not synchronously
// in the effect body. The bar lingers 240ms after the last request settles so a burst of quick calls does
// not strobe it.
useEffect(() => {
if (active) {
setVisible(true);
return;
}
if (active) return;
const t = window.setTimeout(() => setVisible(false), 240);
return () => clearTimeout(t);
}, [active]);
Expand Down
9 changes: 3 additions & 6 deletions apps/loopover-ui/src/components/site/api-status-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,9 @@ export function ApiStatusBanner() {
const visibleKey = severity ? `${connection}:${status}` : null;
const dismissed = visibleKey !== null && dismissedKey === visibleKey;

// If status changes to a new failure mode, un-dismiss.
useEffect(() => {
if (visibleKey && dismissedKey && dismissedKey !== visibleKey) {
setDismissedKey(null);
}
}, [visibleKey, dismissedKey]);
// A new failure mode un-dismisses the banner by DERIVATION: `dismissed` above compares the stored key
// against the current one, so a stale key already reads as not-dismissed (#9588). The effect that used to
// null it out changed no rendered output -- it only scrubbed state nothing consulted.

if (!severity || dismissed) return null;

Expand Down
14 changes: 5 additions & 9 deletions apps/loopover-ui/src/components/site/api/try-it.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
const LEGACY_STORAGE_KEY = "gittensory.session_token";

/** Reads the session token, falling back to (and migrating forward from) the pre-rebrand legacy key. */
export function readStoredSessionToken(storage: Pick<Storage, "getItem" | "setItem">): string {

Check warning on line 24 in apps/loopover-ui/src/components/site/api/try-it.tsx

View workflow job for this annotation

GitHub Actions / validate-code

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const stored = storage.getItem(STORAGE_KEY);
if (stored !== null) return stored;
const legacy = storage.getItem(LEGACY_STORAGE_KEY);
Expand Down Expand Up @@ -55,7 +55,11 @@
: "API timing out — actions paused"
: null;

const [token, setToken] = useState("");
// Read once, lazily: localStorage is only touched on the client, and this component is keyed on the
// operation so a switch remounts it and re-reads rather than needing an effect to reset (#9588).
const [token, setToken] = useState(() =>
typeof window === "undefined" ? "" : readStoredSessionToken(window.localStorage),
);
const [show, setShow] = useState(false);
const [pathParams, setPathParams] = useState<Record<string, string>>({});
const [queryParams, setQueryParams] = useState<Record<string, string>>({});
Expand All @@ -65,14 +69,6 @@
const [error, setError] = useState<string | null>(null);
const runRef = useRef<() => Promise<void>>(() => Promise.resolve());

useEffect(() => {
setToken(readStoredSessionToken(localStorage));
setResult(null);
setError(null);
setPathParams({});
setQueryParams({});
}, [op.id]);

const saveToken = (v: string) => {
setToken(v);
if (v) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { screen, fireEvent, waitFor, within } from "@testing-library/react";
import { renderWithQueryClient as render } from "@/lib/test-query-client";
import { beforeEach, describe, expect, it, vi } from "vitest";

// Mock the API layer so the component never touches the network.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CheckCircle2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";

import { StatusPill, type Status } from "@/components/site/control-primitives";
import { TableScroll } from "@/components/site/data-table";
Expand Down Expand Up @@ -58,49 +59,34 @@ function repoApiBase(repoFullName: string): string | null {
export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr: string }> }) {
const repoOptions = useMemo(() => extractPreviewRepoOptions(reviewability), [reviewability]);
const [repoFullName, setRepoFullName] = useState(repoOptions[0] ?? "");
const [preview, setPreview] = useState<ActivationPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);

const base = repoApiBase(repoFullName);
const hasRepos = repoOptions.length > 0;

const load = useCallback(
async (opts?: { cancelled?: () => boolean }) => {
const isCancelled = opts?.cancelled ?? (() => false);
const apiBase = repoApiBase(repoFullName);
if (!apiBase) {
setPreview(null);
setLoadError(null);
return;
}
setLoadError(null);
setLoading(true);
const result = await apiFetch<ActivationPreviewResponse>(`${apiBase}/activation-preview`, {
// react-query rather than a hand-rolled load effect (#9588): keying on the repo is what drops a
// response that a newer repo selection has already superseded, which the `cancelled` callback this
// replaces did by hand. Options are pinned to match the previous behaviour exactly -- one attempt, no
// refetch on focus, nothing cached across mounts.
const query = useQuery({
queryKey: ["activation-preview", base],
enabled: base !== null && base !== "",
retry: false,
refetchOnWindowFocus: false,
gcTime: 0,
queryFn: async () => {
const result = await apiFetch<ActivationPreviewResponse>(`${base}/activation-preview`, {
label: "Activation preview",
credentials: "include",
silentStatus: true,
});
// Ignore responses after a newer repoFullName keyed a fresh load (#7784).
if (isCancelled()) return;
if (result.ok) {
setPreview(result.data);
} else {
setPreview(null);
setLoadError(result.message);
}
setLoading(false);
if (!result.ok) throw new Error(result.message);
return result.data;
},
[repoFullName],
);
});

useEffect(() => {
let cancelled = false;
void load({ cancelled: () => cancelled });
return () => {
cancelled = true;
};
}, [load]);
const preview = query.data ?? null;
const loading = query.isFetching;
const loadError = query.isError ? query.error.message : null;
const load = () => void query.refetch();

return (
<section
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { screen, fireEvent, waitFor } from "@testing-library/react";
import { renderWithQueryClient as render } from "@/lib/test-query-client";
import { beforeEach, describe, expect, it, vi } from "vitest";

// Mock the API layer so the component never touches the network.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { KeyRound, Loader2, Save, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { StatusPill } from "@/components/site/control-primitives";
import { apiFetch } from "@/lib/api/request";
import { getApiOrigin } from "@/lib/api/origin";
Expand Down Expand Up @@ -55,52 +56,55 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:
const [keyInput, setKeyInput] = useState("");
const [keyStatus, setKeyStatus] = useState<AiKeyStatus | null>(null);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<Message | null>(null);

const base = repoApiBase(repoFullName);
const hasRepos = repoOptions.length > 0;

const load = useCallback(
async (opts?: { cancelled?: () => boolean }) => {
const isCancelled = opts?.cancelled ?? (() => false);
const apiBase = repoApiBase(repoFullName);
if (!apiBase) return;
setMessage(null);
setLoading(true);
// react-query rather than a hand-rolled load effect (#9588): keying on the repo drops a response a newer
// selection has already superseded, which the `cancelled` callback this replaces did by hand. Options are
// pinned to the previous behaviour -- one attempt, no focus refetch, nothing cached across mounts.
const query = useQuery({
queryKey: ["ai-review-settings", base],
enabled: base !== null && base !== "",
retry: false,
refetchOnWindowFocus: false,
gcTime: 0,
queryFn: async () => {
const [settings, key] = await Promise.all([
apiFetch<RepoSettingsResponse>(`${apiBase}/settings`, {
apiFetch<RepoSettingsResponse>(`${base}/settings`, {
label: "AI review settings",
credentials: "include",
silentStatus: true,
}),
apiFetch<AiKeyStatus>(`${apiBase}/ai-key`, {
apiFetch<AiKeyStatus>(`${base}/ai-key`, {
label: "AI key status",
credentials: "include",
silentStatus: true,
}),
]);
// Ignore responses after a newer repoFullName keyed a fresh load (#7784).
if (isCancelled()) return;
if (settings.ok) {
setMode(settings.data.aiReviewMode ?? "off");
setByok(settings.data.aiReviewByok ?? false);
setProvider(settings.data.aiReviewProvider ?? "anthropic");
setModel(settings.data.aiReviewModel ?? "");
}
setKeyStatus(key.ok ? key.data : null);
setLoading(false);
return { settings: settings.ok ? settings.data : null, key: key.ok ? key.data : null };
},
[repoFullName],
);
});

const loading = query.isFetching;
const load = () => void query.refetch();

useEffect(() => {
let cancelled = false;
void load({ cancelled: () => cancelled });
return () => {
cancelled = true;
};
}, [load]);
// Seed the editable fields from whatever the last response carried. Adjusted during render (React's
// documented pattern) and gated on `dataUpdatedAt`, so a fresh response re-seeds but a user's own edits
// survive every re-render in between.
const [seededAt, setSeededAt] = useState<number | null>(null);
if (query.data && seededAt !== query.dataUpdatedAt) {
setSeededAt(query.dataUpdatedAt);
const settings = query.data.settings;
if (settings) {
setMode(settings.aiReviewMode ?? "off");
setByok(settings.aiReviewByok ?? false);
setProvider(settings.aiReviewProvider ?? "anthropic");
setModel(settings.aiReviewModel ?? "");
}
setKeyStatus(query.data.key);
}

async function saveKey() {
if (!base) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { screen, fireEvent, waitFor } from "@testing-library/react";
import { renderWithQueryClient as render } from "@/lib/test-query-client";
import { beforeEach, describe, expect, it, vi } from "vitest";

// Mock the API layer so the component never touches the network.
Expand Down
Loading
Loading