perf: reduce dashboard SSE refresh and render churn (#451)
* perf: coalesce dashboard session refreshes Batch membership-driven refreshes behind a short cadence and keep low-frequency steady-state refreshes so pause and PR data stay current without hammering /api/sessions during SSE churn. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * perf: memoize dashboard session views Keep unchanged zones and cards cold during same-membership SSE updates by stabilizing dashboard callbacks, caching project groupings, and memoizing the session view tree with a render-cadence regression test. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: prevent stale refresh scheduling after project switch Abort in-flight refreshes and ignore late completions from disposed effects so old project fetches cannot re-arm timers or reset the new project state. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --------- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
b064925621
commit
4e93bc1458
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import type { DashboardSession, AttentionLevel } from "@/lib/types";
|
||||
import { SessionCard } from "./SessionCard";
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ const zoneConfig: Record<
|
|||
},
|
||||
};
|
||||
|
||||
export function AttentionZone({
|
||||
function AttentionZoneView({
|
||||
level,
|
||||
sessions,
|
||||
variant = "grid",
|
||||
|
|
@ -76,10 +76,7 @@ export function AttentionZone({
|
|||
className="mb-2.5 flex items-center gap-2 py-0.5 text-left"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<div
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{ background: config.color }}
|
||||
/>
|
||||
<div className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: config.color }} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
|
||||
{config.label}
|
||||
</span>
|
||||
|
|
@ -128,10 +125,7 @@ export function AttentionZone({
|
|||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
{/* Semantic dot — only zone-colored element */}
|
||||
<div
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{ background: config.color }}
|
||||
/>
|
||||
<div className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: config.color }} />
|
||||
{/* Label — neutral, not zone-colored */}
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
|
||||
{config.label}
|
||||
|
|
@ -172,3 +166,18 @@ export function AttentionZone({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function areAttentionZonePropsEqual(prev: AttentionZoneProps, next: AttentionZoneProps): boolean {
|
||||
return (
|
||||
prev.level === next.level &&
|
||||
prev.variant === next.variant &&
|
||||
prev.onSend === next.onSend &&
|
||||
prev.onKill === next.onKill &&
|
||||
prev.onMerge === next.onMerge &&
|
||||
prev.onRestore === next.onRestore &&
|
||||
prev.sessions.length === next.sessions.length &&
|
||||
prev.sessions.every((session, index) => session === next.sessions[index])
|
||||
);
|
||||
}
|
||||
|
||||
export const AttentionZone = memo(AttentionZoneView, areAttentionZonePropsEqual);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
type DashboardSession,
|
||||
type DashboardStats,
|
||||
|
|
@ -86,6 +86,19 @@ export function Dashboard({
|
|||
return zones;
|
||||
}, [sessions]);
|
||||
|
||||
const sessionsByProject = useMemo(() => {
|
||||
const groupedSessions = new Map<string, DashboardSession[]>();
|
||||
for (const session of sessions) {
|
||||
const projectSessions = groupedSessions.get(session.projectId);
|
||||
if (projectSessions) {
|
||||
projectSessions.push(session);
|
||||
continue;
|
||||
}
|
||||
groupedSessions.set(session.projectId, [session]);
|
||||
}
|
||||
return groupedSessions;
|
||||
}, [sessions]);
|
||||
|
||||
const openPRs = useMemo(() => {
|
||||
return sessions
|
||||
.filter(
|
||||
|
|
@ -100,7 +113,7 @@ export function Dashboard({
|
|||
if (!allProjectsView) return [];
|
||||
|
||||
return projects.map((project) => {
|
||||
const projectSessions = sessions.filter((session) => session.projectId === project.id);
|
||||
const projectSessions = sessionsByProject.get(project.id) ?? [];
|
||||
const counts: Record<AttentionLevel, number> = {
|
||||
merge: 0,
|
||||
respond: 0,
|
||||
|
|
@ -123,9 +136,9 @@ export function Dashboard({
|
|||
counts,
|
||||
};
|
||||
});
|
||||
}, [activeOrchestrators, allProjectsView, projects, sessions]);
|
||||
}, [activeOrchestrators, allProjectsView, projects, sessionsByProject]);
|
||||
|
||||
const handleSend = async (sessionId: string, message: string) => {
|
||||
const handleSend = useCallback(async (sessionId: string, message: string) => {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -134,9 +147,9 @@ export function Dashboard({
|
|||
if (!res.ok) {
|
||||
console.error(`Failed to send message to ${sessionId}:`, await res.text());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleKill = async (sessionId: string) => {
|
||||
const handleKill = useCallback(async (sessionId: string) => {
|
||||
if (!confirm(`Kill session ${sessionId}?`)) return;
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/kill`, {
|
||||
method: "POST",
|
||||
|
|
@ -144,16 +157,16 @@ export function Dashboard({
|
|||
if (!res.ok) {
|
||||
console.error(`Failed to kill ${sessionId}:`, await res.text());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMerge = async (prNumber: number) => {
|
||||
const handleMerge = useCallback(async (prNumber: number) => {
|
||||
const res = await fetch(`/api/prs/${prNumber}/merge`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to merge PR #${prNumber}:`, await res.text());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRestore = async (sessionId: string) => {
|
||||
const handleRestore = useCallback(async (sessionId: string) => {
|
||||
if (!confirm(`Restore session ${sessionId}?`)) return;
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/restore`, {
|
||||
method: "POST",
|
||||
|
|
@ -161,7 +174,7 @@ export function Dashboard({
|
|||
if (!res.ok) {
|
||||
console.error(`Failed to restore ${sessionId}:`, await res.text());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSpawnOrchestrator = async (project: ProjectInfo) => {
|
||||
setSpawningProjectIds((current) =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { memo, useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
type DashboardSession,
|
||||
type AttentionLevel,
|
||||
|
|
@ -25,15 +25,15 @@ interface SessionCardProps {
|
|||
}
|
||||
|
||||
const borderColorByLevel: Record<AttentionLevel, string> = {
|
||||
merge: "border-l-[var(--color-status-ready)]",
|
||||
merge: "border-l-[var(--color-status-ready)]",
|
||||
respond: "border-l-[var(--color-status-error)]",
|
||||
review: "border-l-[var(--color-accent-orange)]",
|
||||
review: "border-l-[var(--color-accent-orange)]",
|
||||
pending: "border-l-[var(--color-status-attention)]",
|
||||
working: "border-l-[var(--color-status-working)]",
|
||||
done: "border-l-[var(--color-border-default)]",
|
||||
done: "border-l-[var(--color-border-default)]",
|
||||
};
|
||||
|
||||
export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) {
|
||||
function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [sendingAction, setSendingAction] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
|
@ -41,7 +41,9 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
const pr = session.pr;
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAction = async (action: string, message: string) => {
|
||||
|
|
@ -75,9 +77,10 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
)}
|
||||
style={{
|
||||
borderRadius: 7,
|
||||
background: (expanded && !isReadyToMerge)
|
||||
? "linear-gradient(175deg, rgba(32,41,53,1) 0%, rgba(22,28,37,1) 100%)"
|
||||
: undefined,
|
||||
background:
|
||||
expanded && !isReadyToMerge
|
||||
? "linear-gradient(175deg, rgba(32,41,53,1) 0%, rgba(22,28,37,1) 100%)"
|
||||
: undefined,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a, button, textarea")) return;
|
||||
|
|
@ -93,7 +96,10 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
<div className="flex-1" />
|
||||
{isRestorable && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRestore?.(session.id); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRestore?.(session.id);
|
||||
}}
|
||||
className="rounded border border-[rgba(88,166,255,0.35)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[rgba(88,166,255,0.1)]"
|
||||
>
|
||||
restore
|
||||
|
|
@ -112,12 +118,14 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
|
||||
{/* Title — its own row, bigger, can wrap */}
|
||||
<div className="px-4 pb-3">
|
||||
<p className={cn(
|
||||
"leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3] overflow-hidden",
|
||||
level === "working"
|
||||
? "text-[13px] font-medium text-[var(--color-text-secondary)]"
|
||||
: "text-[14px] font-semibold text-[var(--color-text-primary)]"
|
||||
)}>
|
||||
<p
|
||||
className={cn(
|
||||
"leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3] overflow-hidden",
|
||||
level === "working"
|
||||
? "text-[13px] font-medium text-[var(--color-text-secondary)]"
|
||||
: "text-[14px] font-semibold text-[var(--color-text-primary)]",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -139,7 +147,13 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
{rateLimited && pr?.state === "open" && (
|
||||
<div className="px-4 pb-3">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
|
||||
<svg className="h-3 w-3 text-[var(--color-text-tertiary)]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<svg
|
||||
className="h-3 w-3 text-[var(--color-text-tertiary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
|
|
@ -153,10 +167,19 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
<div className="px-4 pb-3.5 pt-0.5">
|
||||
{isReadyToMerge && pr ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onMerge?.(pr.number); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMerge?.(pr.number);
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-[5px] border-0 bg-[var(--color-status-ready)] px-3 py-1.5 text-[12px] font-semibold text-[var(--color-text-inverse)] transition-[filter,transform] duration-[100ms] hover:-translate-y-px hover:brightness-110"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
Merge PR #{pr.number}
|
||||
|
|
@ -180,7 +203,10 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
</a>
|
||||
{alert.actionLabel && session.activity !== "active" && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleAction(alert.key, alert.actionMessage ?? ""); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(alert.key, alert.actionMessage ?? "");
|
||||
}}
|
||||
disabled={sendingAction === alert.key}
|
||||
className="rounded border border-[rgba(88,166,255,0.25)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[rgba(88,166,255,0.1)] disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -230,11 +256,18 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
<div className="space-y-1">
|
||||
{pr.unresolvedComments.map((c) => (
|
||||
<div key={c.url} className="flex items-center gap-2 text-[12px]">
|
||||
<span className="w-3 shrink-0 text-center text-[var(--color-status-error)]">●</span>
|
||||
<span className="w-3 shrink-0 text-center text-[var(--color-status-error)]">
|
||||
●
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[10px] text-[var(--color-text-secondary)]">
|
||||
{c.path}
|
||||
</span>
|
||||
<a href={c.url} target="_blank" rel="noopener noreferrer" className="shrink-0 text-[11px] text-[var(--color-accent)] hover:underline">
|
||||
<a
|
||||
href={c.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 text-[11px] text-[var(--color-accent)] hover:underline"
|
||||
>
|
||||
view →
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -246,7 +279,14 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
{pr && (
|
||||
<DetailSection label="PR">
|
||||
<p className="text-[12px] text-[var(--color-text-secondary)]">
|
||||
<a href={pr.url} target="_blank" rel="noopener noreferrer" className="hover:underline">{pr.title}</a>
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{pr.title}
|
||||
</a>
|
||||
<br />
|
||||
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
|
||||
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>
|
||||
|
|
@ -257,13 +297,18 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
)}
|
||||
|
||||
{!pr && (
|
||||
<p className="text-[12px] text-[var(--color-text-tertiary)]">No PR associated with this session.</p>
|
||||
<p className="text-[12px] text-[var(--color-text-tertiary)]">
|
||||
No PR associated with this session.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex gap-2 border-t border-[var(--color-border-subtle)] pt-3">
|
||||
{isRestorable && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRestore?.(session.id); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRestore?.(session.id);
|
||||
}}
|
||||
className="rounded border border-[rgba(88,166,255,0.35)] px-2.5 py-1 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[rgba(88,166,255,0.1)]"
|
||||
>
|
||||
restore session
|
||||
|
|
@ -271,7 +316,10 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
)}
|
||||
{!isTerminal && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onKill?.(session.id); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKill?.(session.id);
|
||||
}}
|
||||
className="rounded border border-[rgba(239,68,68,0.35)] px-2.5 py-1 text-[11px] text-[var(--color-status-error)] transition-colors hover:bg-[rgba(239,68,68,0.1)]"
|
||||
>
|
||||
terminate
|
||||
|
|
@ -284,6 +332,18 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
|
|||
);
|
||||
}
|
||||
|
||||
function areSessionCardPropsEqual(prev: SessionCardProps, next: SessionCardProps): boolean {
|
||||
return (
|
||||
prev.session === next.session &&
|
||||
prev.onSend === next.onSend &&
|
||||
prev.onKill === next.onKill &&
|
||||
prev.onMerge === next.onMerge &&
|
||||
prev.onRestore === next.onRestore
|
||||
);
|
||||
}
|
||||
|
||||
export const SessionCard = memo(SessionCardView, areSessionCardPropsEqual);
|
||||
|
||||
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-2.5">
|
||||
|
|
@ -319,14 +379,16 @@ function getAlerts(session: DashboardSession): Alert[] {
|
|||
alerts.push({
|
||||
key: "ci-unknown",
|
||||
label: "CI unknown",
|
||||
className: "border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]",
|
||||
className:
|
||||
"border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]",
|
||||
url: pr.url + "/checks",
|
||||
});
|
||||
} else {
|
||||
alerts.push({
|
||||
key: "ci-fail",
|
||||
label: `${failCount} CI check${failCount > 1 ? "s" : ""} failing`,
|
||||
className: "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
|
||||
className:
|
||||
"border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
|
||||
url: failedCheck?.url ?? pr.url + "/checks",
|
||||
actionLabel: "ask to fix",
|
||||
actionMessage: `Please fix the failing CI checks on ${pr.url}`,
|
||||
|
|
@ -338,14 +400,16 @@ function getAlerts(session: DashboardSession): Alert[] {
|
|||
alerts.push({
|
||||
key: "changes",
|
||||
label: "changes requested",
|
||||
className: "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
|
||||
className:
|
||||
"border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
|
||||
url: pr.url,
|
||||
});
|
||||
} else if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {
|
||||
alerts.push({
|
||||
key: "review",
|
||||
label: "needs review",
|
||||
className: "border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]",
|
||||
className:
|
||||
"border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]",
|
||||
url: pr.url,
|
||||
actionLabel: "ask to post",
|
||||
actionMessage: `Post ${pr.url} on slack asking for a review.`,
|
||||
|
|
@ -356,7 +420,8 @@ function getAlerts(session: DashboardSession): Alert[] {
|
|||
alerts.push({
|
||||
key: "conflict",
|
||||
label: "merge conflict",
|
||||
className: "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
|
||||
className:
|
||||
"border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
|
||||
url: pr.url,
|
||||
actionLabel: "ask to fix",
|
||||
actionMessage: `Please resolve the merge conflicts on ${pr.url} by rebasing on the base branch`,
|
||||
|
|
@ -369,7 +434,8 @@ function getAlerts(session: DashboardSession): Alert[] {
|
|||
key: "comments",
|
||||
label: "unresolved comments",
|
||||
count: pr.unresolvedThreads,
|
||||
className: "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
|
||||
className:
|
||||
"border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
|
||||
url: firstUrl,
|
||||
actionLabel: "ask to resolve",
|
||||
actionMessage: `Please address all unresolved review comments on ${pr.url}`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { memo } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const renderCounts = new Map<string, number>();
|
||||
|
||||
vi.mock("@/components/SessionCard", () => ({
|
||||
SessionCard: memo(({ session }: { session: { id: string } }) => {
|
||||
renderCounts.set(session.id, (renderCounts.get(session.id) ?? 0) + 1);
|
||||
return <div data-testid={`session-card-${session.id}`}>{session.id}</div>;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { Dashboard } from "../Dashboard";
|
||||
import { makeSession } from "../../__tests__/helpers";
|
||||
|
||||
describe("Dashboard render cadence", () => {
|
||||
let eventSourceMock: {
|
||||
onmessage: ((event: MessageEvent) => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
renderCounts.clear();
|
||||
eventSourceMock = {
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
close: vi.fn(),
|
||||
};
|
||||
const eventSourceConstructor = vi.fn(() => eventSourceMock as unknown as EventSource);
|
||||
global.EventSource = Object.assign(eventSourceConstructor, {
|
||||
CONNECTING: 0,
|
||||
OPEN: 1,
|
||||
CLOSED: 2,
|
||||
}) as unknown as typeof EventSource;
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("rerenders only the changed session card for same-membership snapshots", async () => {
|
||||
const initialSessions = [
|
||||
makeSession({ id: "session-1", summary: "First session" }),
|
||||
makeSession({ id: "session-2", summary: "Second session" }),
|
||||
];
|
||||
|
||||
render(<Dashboard initialSessions={initialSessions} />);
|
||||
|
||||
expect(renderCounts.get("session-1")).toBe(1);
|
||||
expect(renderCounts.get("session-2")).toBe(1);
|
||||
|
||||
await waitFor(() => expect(eventSourceMock.onmessage).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
eventSourceMock.onmessage!({
|
||||
data: JSON.stringify({
|
||||
type: "snapshot",
|
||||
sessions: [
|
||||
{
|
||||
id: "session-1",
|
||||
status: "working",
|
||||
activity: "idle",
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "session-2",
|
||||
status: initialSessions[1].status,
|
||||
activity: initialSessions[1].activity,
|
||||
lastActivityAt: initialSessions[1].lastActivityAt,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as MessageEvent);
|
||||
});
|
||||
|
||||
expect(renderCounts.get("session-1")).toBe(2);
|
||||
expect(renderCounts.get("session-2")).toBe(1);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useSessionEvents } from "../useSessionEvents";
|
||||
import type { DashboardSession, GlobalPauseState } from "@/lib/types";
|
||||
import { makeSession } from "@/__tests__/helpers";
|
||||
import type { DashboardSession, GlobalPauseState } from "../../lib/types";
|
||||
import { makeSession } from "../../__tests__/helpers";
|
||||
|
||||
describe("useSessionEvents", () => {
|
||||
let eventSourceMock: {
|
||||
|
|
@ -14,7 +14,7 @@ describe("useSessionEvents", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
eventSourceInstances = [];
|
||||
global.EventSource = vi.fn(() => {
|
||||
const eventSourceConstructor = vi.fn(() => {
|
||||
const instance = {
|
||||
onmessage: null as ((event: MessageEvent) => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
|
|
@ -24,10 +24,16 @@ describe("useSessionEvents", () => {
|
|||
eventSourceMock = instance;
|
||||
return instance as unknown as EventSource;
|
||||
});
|
||||
global.EventSource = Object.assign(eventSourceConstructor, {
|
||||
CONNECTING: 0,
|
||||
OPEN: 1,
|
||||
CLOSED: 2,
|
||||
}) as unknown as typeof EventSource;
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -73,7 +79,7 @@ describe("useSessionEvents", () => {
|
|||
sessions: [...initialSessions, makeSession({ id: "session-new" })],
|
||||
globalPause: makeGlobalPause({ reason: "Updated pause from different provider" }),
|
||||
}),
|
||||
} as Response);
|
||||
} as unknown as Response);
|
||||
|
||||
const { result } = renderHook(() => useSessionEvents(initialSessions, initialPause));
|
||||
|
||||
|
|
@ -107,7 +113,9 @@ describe("useSessionEvents", () => {
|
|||
} as MessageEvent);
|
||||
});
|
||||
|
||||
expect(result.current.globalPause?.reason).toBe("Updated pause from different provider");
|
||||
await waitFor(() => {
|
||||
expect(result.current.globalPause?.reason).toBe("Updated pause from different provider");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears globalPause when /api/sessions returns null pause", async () => {
|
||||
|
|
@ -120,7 +128,7 @@ describe("useSessionEvents", () => {
|
|||
sessions: [...initialSessions, makeSession({ id: "session-new" })],
|
||||
globalPause: null,
|
||||
}),
|
||||
} as Response);
|
||||
} as unknown as Response);
|
||||
|
||||
const { result } = renderHook(() => useSessionEvents(initialSessions, initialPause));
|
||||
|
||||
|
|
@ -154,7 +162,9 @@ describe("useSessionEvents", () => {
|
|||
} as MessageEvent);
|
||||
});
|
||||
|
||||
expect(result.current.globalPause).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(result.current.globalPause).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("sets globalPause when initially null and /api/sessions returns pause", async () => {
|
||||
|
|
@ -200,7 +210,44 @@ describe("useSessionEvents", () => {
|
|||
} as MessageEvent);
|
||||
});
|
||||
|
||||
expect(result.current.globalPause?.reason).toBe("New rate limit detected");
|
||||
await waitFor(() => {
|
||||
expect(result.current.globalPause?.reason).toBe("New rate limit detected");
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes globalPause on stale same-membership snapshots without waiting for membership churn", async () => {
|
||||
vi.useFakeTimers();
|
||||
const initialSessions = makeSessions(2);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
sessions: initialSessions,
|
||||
globalPause: makeGlobalPause({ reason: "Pause updated during steady-state SSE" }),
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const { result } = renderHook(() => useSessionEvents(initialSessions, null));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
eventSourceMock!.onmessage!.call(eventSourceMock, {
|
||||
data: JSON.stringify({
|
||||
type: "snapshot",
|
||||
sessions: initialSessions.map((session) => ({
|
||||
id: session.id,
|
||||
status: session.status,
|
||||
activity: session.activity,
|
||||
lastActivityAt: session.lastActivityAt,
|
||||
})),
|
||||
}),
|
||||
} as MessageEvent);
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith("/api/sessions", { signal: expect.any(AbortSignal) });
|
||||
expect(result.current.globalPause?.reason).toBe("Pause updated during steady-state SSE");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -238,8 +285,10 @@ describe("useSessionEvents", () => {
|
|||
} as MessageEvent);
|
||||
});
|
||||
|
||||
expect(result.current.globalPause?.reason).toBe("usage limit reached for 2 hours");
|
||||
expect(result.current.globalPause?.sourceSessionId).toBe("claude-session-1");
|
||||
await waitFor(() => {
|
||||
expect(result.current.globalPause?.reason).toBe("usage limit reached for 2 hours");
|
||||
expect(result.current.globalPause?.sourceSessionId).toBe("claude-session-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles globalPause from OpenCode agent without provider-specific logic", async () => {
|
||||
|
|
@ -275,8 +324,10 @@ describe("useSessionEvents", () => {
|
|||
} as MessageEvent);
|
||||
});
|
||||
|
||||
expect(result.current.globalPause?.reason).toBe("Model capacity exceeded");
|
||||
expect(result.current.globalPause?.sourceSessionId).toBe("opencode-session-42");
|
||||
await waitFor(() => {
|
||||
expect(result.current.globalPause?.reason).toBe("Model capacity exceeded");
|
||||
expect(result.current.globalPause?.sourceSessionId).toBe("opencode-session-42");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles globalPause from Codex agent without provider-specific logic", async () => {
|
||||
|
|
@ -312,8 +363,10 @@ describe("useSessionEvents", () => {
|
|||
} as MessageEvent);
|
||||
});
|
||||
|
||||
expect(result.current.globalPause?.reason).toBe("API quota exhausted");
|
||||
expect(result.current.globalPause?.sourceSessionId).toBe("codex-worker-99");
|
||||
await waitFor(() => {
|
||||
expect(result.current.globalPause?.reason).toBe("API quota exhausted");
|
||||
expect(result.current.globalPause?.sourceSessionId).toBe("codex-worker-99");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -344,6 +397,184 @@ describe("useSessionEvents", () => {
|
|||
expect(result.current.sessions[1].status).toBe("working");
|
||||
});
|
||||
|
||||
it("preserves untouched session references across snapshot patches", async () => {
|
||||
const sessions = makeSessions(2);
|
||||
|
||||
const { result } = renderHook(() => useSessionEvents(sessions, null));
|
||||
const untouchedSession = result.current.sessions[1];
|
||||
|
||||
await act(async () => {
|
||||
eventSourceMock!.onmessage!.call(eventSourceMock, {
|
||||
data: JSON.stringify({
|
||||
type: "snapshot",
|
||||
sessions: [
|
||||
{
|
||||
id: "session-0",
|
||||
status: "pr_open",
|
||||
activity: "idle",
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as MessageEvent);
|
||||
});
|
||||
|
||||
expect(result.current.sessions[0]).not.toBe(sessions[0]);
|
||||
expect(result.current.sessions[1]).toBe(untouchedSession);
|
||||
});
|
||||
|
||||
it("coalesces bursty membership changes into a single refresh fetch", async () => {
|
||||
vi.useFakeTimers();
|
||||
const initialSessions = makeSessions(1);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
sessions: [
|
||||
...initialSessions,
|
||||
makeSession({ id: "session-1" }),
|
||||
makeSession({ id: "session-2" }),
|
||||
makeSession({ id: "session-3" }),
|
||||
],
|
||||
globalPause: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderHook(() => useSessionEvents(initialSessions, null));
|
||||
|
||||
await act(async () => {
|
||||
for (const sessionId of ["session-1", "session-2", "session-3"]) {
|
||||
eventSourceMock!.onmessage!.call(eventSourceMock, {
|
||||
data: JSON.stringify({
|
||||
type: "snapshot",
|
||||
sessions: [
|
||||
{
|
||||
id: "session-0",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: sessionId,
|
||||
status: "working",
|
||||
activity: "active",
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as MessageEvent);
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith("/api/sessions", { signal: expect.any(AbortSignal) });
|
||||
});
|
||||
|
||||
it("ignores stale refresh completions after project changes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const alphaSessions = [makeSession({ id: "alpha-0", projectId: "alpha" })];
|
||||
const betaSessions = [makeSession({ id: "beta-0", projectId: "beta" })];
|
||||
let resolveAlphaFetch: ((value: Response) => void) | null = null;
|
||||
|
||||
vi.mocked(fetch).mockImplementation((input) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("project=alpha")) {
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveAlphaFetch = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
sessions: [...betaSessions, makeSession({ id: "beta-1", projectId: "beta" })],
|
||||
globalPause: null,
|
||||
}),
|
||||
} as unknown as Response);
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ sessions, project }) => useSessionEvents(sessions, null, project),
|
||||
{
|
||||
initialProps: { sessions: alphaSessions, project: "alpha" },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
eventSourceInstances[0].onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "snapshot",
|
||||
sessions: [
|
||||
{
|
||||
id: "alpha-0",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
lastActivityAt: alphaSessions[0].lastActivityAt,
|
||||
},
|
||||
{
|
||||
id: "alpha-1",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as MessageEvent);
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenNthCalledWith(1, "/api/sessions?project=alpha", {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
|
||||
rerender({ sessions: betaSessions, project: "beta" });
|
||||
|
||||
await act(async () => {
|
||||
resolveAlphaFetch?.({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
sessions: [...alphaSessions, makeSession({ id: "alpha-1", projectId: "alpha" })],
|
||||
globalPause: null,
|
||||
}),
|
||||
} as unknown as Response);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.sessions).toEqual(betaSessions);
|
||||
|
||||
await act(async () => {
|
||||
eventSourceInstances[1].onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "snapshot",
|
||||
sessions: [
|
||||
{
|
||||
id: "beta-0",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
lastActivityAt: betaSessions[0].lastActivityAt,
|
||||
},
|
||||
{
|
||||
id: "beta-1",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as MessageEvent);
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(fetch).toHaveBeenNthCalledWith(2, "/api/sessions?project=beta", {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it("swallows refresh fetch JSON failures without resetting sessions", async () => {
|
||||
const sessions = makeSessions(1);
|
||||
|
||||
|
|
@ -352,7 +583,7 @@ describe("useSessionEvents", () => {
|
|||
json: async () => {
|
||||
throw new Error("bad json");
|
||||
},
|
||||
} as Response);
|
||||
} as unknown as Response);
|
||||
|
||||
const { result } = renderHook(() => useSessionEvents(sessions, null));
|
||||
|
||||
|
|
@ -376,8 +607,12 @@ describe("useSessionEvents", () => {
|
|||
],
|
||||
}),
|
||||
} as MessageEvent);
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith("/api/sessions", {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
import { useEffect, useReducer, useRef } from "react";
|
||||
import type { DashboardSession, GlobalPauseState, SSESnapshotEvent } from "@/lib/types";
|
||||
|
||||
const MEMBERSHIP_REFRESH_DELAY_MS = 120;
|
||||
const STALE_REFRESH_INTERVAL_MS = 15000;
|
||||
|
||||
interface State {
|
||||
sessions: DashboardSession[];
|
||||
globalPause: GlobalPauseState | null;
|
||||
|
|
@ -42,6 +45,15 @@ function reducer(state: State, action: Action): State {
|
|||
}
|
||||
}
|
||||
|
||||
function createMembershipKey(
|
||||
sessions: Array<Pick<DashboardSession, "id">> | SSESnapshotEvent["sessions"],
|
||||
): string {
|
||||
return sessions
|
||||
.map((session) => session.id)
|
||||
.sort()
|
||||
.join("\u0000");
|
||||
}
|
||||
|
||||
export function useSessionEvents(
|
||||
initialSessions: DashboardSession[],
|
||||
initialGlobalPause?: GlobalPauseState | null,
|
||||
|
|
@ -53,6 +65,9 @@ export function useSessionEvents(
|
|||
});
|
||||
const sessionsRef = useRef(state.sessions);
|
||||
const refreshingRef = useRef(false);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingMembershipKeyRef = useRef<string | null>(null);
|
||||
const lastRefreshAtRef = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
sessionsRef.current = state.sessions;
|
||||
|
|
@ -65,6 +80,69 @@ export function useSessionEvents(
|
|||
useEffect(() => {
|
||||
const url = project ? `/api/events?project=${encodeURIComponent(project)}` : "/api/events";
|
||||
const es = new EventSource(url);
|
||||
let disposed = false;
|
||||
let activeRefreshController: AbortController | null = null;
|
||||
|
||||
const clearRefreshTimer = () => {
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
if (disposed) return;
|
||||
if (refreshingRef.current || refreshTimerRef.current) return;
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
if (disposed) return;
|
||||
refreshTimerRef.current = null;
|
||||
refreshingRef.current = true;
|
||||
const requestedMembershipKey = pendingMembershipKeyRef.current;
|
||||
const refreshController = new AbortController();
|
||||
activeRefreshController = refreshController;
|
||||
|
||||
const sessionsUrl = project
|
||||
? `/api/sessions?project=${encodeURIComponent(project)}`
|
||||
: "/api/sessions";
|
||||
|
||||
void fetch(sessionsUrl, { signal: refreshController.signal })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then(
|
||||
(updated: { sessions?: DashboardSession[]; globalPause?: GlobalPauseState } | null) => {
|
||||
if (disposed || refreshController.signal.aborted || !updated?.sessions) return;
|
||||
|
||||
lastRefreshAtRef.current = Date.now();
|
||||
dispatch({
|
||||
type: "reset",
|
||||
sessions: updated.sessions,
|
||||
globalPause: updated.globalPause ?? null,
|
||||
});
|
||||
},
|
||||
)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (activeRefreshController === refreshController) {
|
||||
activeRefreshController = null;
|
||||
}
|
||||
if (disposed || refreshController.signal.aborted) {
|
||||
refreshingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
refreshingRef.current = false;
|
||||
|
||||
if (
|
||||
pendingMembershipKeyRef.current !== null &&
|
||||
pendingMembershipKeyRef.current !== requestedMembershipKey
|
||||
) {
|
||||
scheduleRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingMembershipKeyRef.current = null;
|
||||
});
|
||||
}, MEMBERSHIP_REFRESH_DELAY_MS);
|
||||
};
|
||||
|
||||
es.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
|
|
@ -73,36 +151,17 @@ export function useSessionEvents(
|
|||
const snapshot = data as SSESnapshotEvent;
|
||||
dispatch({ type: "snapshot", patches: snapshot.sessions });
|
||||
|
||||
const currentIds = new Set(sessionsRef.current.map((s) => s.id));
|
||||
const snapshotIds = new Set(snapshot.sessions.map((s) => s.id));
|
||||
const sameMembership =
|
||||
currentIds.size === snapshotIds.size &&
|
||||
[...snapshotIds].every((id) => currentIds.has(id));
|
||||
const currentMembershipKey = createMembershipKey(sessionsRef.current);
|
||||
const snapshotMembershipKey = createMembershipKey(snapshot.sessions);
|
||||
|
||||
if (!sameMembership && !refreshingRef.current) {
|
||||
refreshingRef.current = true;
|
||||
const sessionsUrl = project
|
||||
? `/api/sessions?project=${encodeURIComponent(project)}`
|
||||
: "/api/sessions";
|
||||
void fetch(sessionsUrl)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then(
|
||||
(
|
||||
updated: { sessions?: DashboardSession[]; globalPause?: GlobalPauseState } | null,
|
||||
) => {
|
||||
if (updated?.sessions) {
|
||||
dispatch({
|
||||
type: "reset",
|
||||
sessions: updated.sessions,
|
||||
globalPause: updated.globalPause ?? null,
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
refreshingRef.current = false;
|
||||
});
|
||||
if (currentMembershipKey !== snapshotMembershipKey) {
|
||||
pendingMembershipKeyRef.current = snapshotMembershipKey;
|
||||
scheduleRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - lastRefreshAtRef.current >= STALE_REFRESH_INTERVAL_MS) {
|
||||
scheduleRefresh();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -113,6 +172,12 @@ export function useSessionEvents(
|
|||
es.onerror = () => undefined;
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
activeRefreshController?.abort();
|
||||
activeRefreshController = null;
|
||||
refreshingRef.current = false;
|
||||
pendingMembershipKeyRef.current = null;
|
||||
clearRefreshTimer();
|
||||
es.close();
|
||||
};
|
||||
}, [project]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue