Merge public-gh/master into fix/hmr-websocket-reverse-proxy

Reconcile the PR with current master, preserve both PWA capability meta tags, and add websocket lifecycle coverage for the StrictMode-safe live updates fix.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-30 07:17:23 -05:00
489 changed files with 164284 additions and 6078 deletions
+171
View File
@@ -0,0 +1,171 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
import { queryKeys } from "../lib/queryKeys";
describe("LiveUpdatesProvider issue invalidation", () => {
it("refreshes touched inbox queries for issue activity", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: () => undefined,
};
__liveUpdatesTestUtils.invalidateActivityQueries(
queryClient as never,
"company-1",
{
entityType: "issue",
entityId: "issue-1",
details: null,
},
);
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.listMineByMe("company-1"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.listTouchedByMe("company-1"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
});
});
});
describe("LiveUpdatesProvider visible issue toast suppression", () => {
it("suppresses activity toasts for the issue page currently in view", () => {
const queryClient = {
getQueryData: (key: unknown) => {
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
return {
id: "issue-1",
identifier: "PAP-759",
assigneeAgentId: "agent-1",
};
}
return undefined;
},
};
expect(
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
queryClient as never,
"/PAP/issues/PAP-759",
{
entityType: "issue",
entityId: "issue-1",
details: { identifier: "PAP-759" },
},
{ isForegrounded: true },
),
).toBe(true);
expect(
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
queryClient as never,
"/PAP/issues/PAP-759",
{
entityType: "issue",
entityId: "issue-2",
details: { identifier: "PAP-760" },
},
{ isForegrounded: true },
),
).toBe(false);
});
it("suppresses run and agent status toasts for the assignee of the visible issue", () => {
const queryClient = {
getQueryData: (key: unknown) => {
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
return {
id: "issue-1",
identifier: "PAP-759",
assigneeAgentId: "agent-1",
};
}
return undefined;
},
};
expect(
__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(
queryClient as never,
"/PAP/issues/PAP-759",
{
runId: "run-1",
agentId: "agent-1",
},
{ isForegrounded: true },
),
).toBe(true);
expect(
__liveUpdatesTestUtils.shouldSuppressAgentStatusToastForVisibleIssue(
queryClient as never,
"/PAP/issues/PAP-759",
{
agentId: "agent-1",
status: "running",
},
{ isForegrounded: true },
),
).toBe(true);
});
});
describe("LiveUpdatesProvider socket helpers", () => {
it("waits for the selected company object to catch up before connecting", () => {
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", null)).toBeNull();
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-2")).toBeNull();
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-1")).toBe("company-1");
});
it("defers close until onopen for sockets that are still connecting", () => {
const socket = {
readyState: 0,
onopen: (() => undefined) as (() => void) | null,
onmessage: (() => undefined) as (() => void) | null,
onerror: (() => undefined) as (() => void) | null,
onclose: (() => undefined) as (() => void) | null,
close: vi.fn(),
};
__liveUpdatesTestUtils.closeSocketQuietly(socket as never, "provider_unmount");
expect(socket.close).not.toHaveBeenCalled();
expect(socket.onmessage).toBeNull();
expect(socket.onclose).toBeNull();
expect(socket.onopen).toBeTypeOf("function");
expect(socket.onerror).toBeTypeOf("function");
socket.onopen?.();
expect(socket.close).toHaveBeenCalledWith(1000, "provider_unmount");
expect(socket.onopen).toBeNull();
expect(socket.onerror).toBeNull();
});
it("closes open sockets immediately without leaving handlers behind", () => {
const socket = {
readyState: 1,
onopen: (() => undefined) as (() => void) | null,
onmessage: (() => undefined) as (() => void) | null,
onerror: (() => undefined) as (() => void) | null,
onclose: (() => undefined) as (() => void) | null,
close: vi.fn(),
};
__liveUpdatesTestUtils.closeSocketQuietly(socket as never, "stale_connection");
expect(socket.close).toHaveBeenCalledWith(1000, "stale_connection");
expect(socket.onopen).toBeNull();
expect(socket.onmessage).toBeNull();
expect(socket.onerror).toBeNull();
expect(socket.onclose).toBeNull();
});
});
+213 -36
View File
@@ -1,15 +1,30 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
import type { RunForIssue } from "../api/activity";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { authApi } from "../api/auth";
import { useCompany } from "./CompanyContext";
import type { ToastInput } from "./ToastContext";
import { useToast } from "./ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { toCompanyRelativePath } from "../lib/company-routes";
import { useLocation } from "../lib/router";
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
const TOAST_COOLDOWN_MAX = 3;
const RECONNECT_SUPPRESS_MS = 2000;
const SOCKET_CONNECTING = 0;
const SOCKET_OPEN = 1;
type LiveUpdatesSocketLike = {
readyState: number;
onopen: ((this: WebSocket, ev: Event) => unknown) | null;
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null;
onerror: ((this: WebSocket, ev: Event) => unknown) | null;
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null;
close: (code?: number, reason?: string) => void;
};
function readString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
@@ -63,6 +78,16 @@ interface IssueToastContext {
href: string;
}
interface VisibleRouteOptions {
isForegrounded?: boolean;
}
interface VisibleIssueRouteContext {
issueRefs: Set<string>;
assigneeAgentId: string | null;
runIds: Set<string>;
}
function resolveIssueQueryRefs(
queryClient: QueryClient,
companyId: string,
@@ -125,6 +150,110 @@ function resolveIssueToastContext(
};
}
function isPageForegrounded(): boolean {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
if (typeof document.hasFocus === "function" && !document.hasFocus()) return false;
return true;
}
function resolveVisibleIssueRouteContext(
queryClient: QueryClient,
pathname: string,
options?: VisibleRouteOptions,
): VisibleIssueRouteContext | null {
const isForegrounded = options?.isForegrounded ?? isPageForegrounded();
if (!isForegrounded) return null;
const relativePath = toCompanyRelativePath(pathname);
const segments = relativePath.split("/").filter(Boolean);
if (segments[0] !== "issues" || !segments[1]) return null;
const issueRef = decodeURIComponent(segments[1]);
const issue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueRef)) ?? null;
const issueRefs = new Set<string>([issueRef]);
if (issue?.id) issueRefs.add(issue.id);
if (issue?.identifier) issueRefs.add(issue.identifier);
const runIds = new Set<string>();
const activeRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueRef));
const liveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueRef)) ?? [];
const linkedRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueRef)) ?? [];
if (activeRun?.id) runIds.add(activeRun.id);
for (const run of liveRuns) {
if (run.id) runIds.add(run.id);
}
for (const run of linkedRuns) {
if (run.runId) runIds.add(run.runId);
}
return {
issueRefs,
assigneeAgentId: issue?.assigneeAgentId ?? null,
runIds,
};
}
function buildIssueRefsForPayload(entityId: string, details: Record<string, unknown> | null): Set<string> {
const refs = new Set<string>([entityId]);
const identifier = readString(details?.identifier) ?? readString(details?.issueIdentifier);
if (identifier) refs.add(identifier);
return refs;
}
function overlaps(a: Set<string>, b: Set<string>): boolean {
for (const value of a) {
if (b.has(value)) return true;
}
return false;
}
function shouldSuppressActivityToastForVisibleIssue(
queryClient: QueryClient,
pathname: string,
payload: Record<string, unknown>,
options?: VisibleRouteOptions,
): boolean {
const entityType = readString(payload.entityType);
const entityId = readString(payload.entityId);
if (entityType !== "issue" || !entityId) return false;
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
if (!context) return false;
return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, readRecord(payload.details)));
}
function shouldSuppressRunStatusToastForVisibleIssue(
queryClient: QueryClient,
pathname: string,
payload: Record<string, unknown>,
options?: VisibleRouteOptions,
): boolean {
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
if (!context) return false;
const runId = readString(payload.runId);
if (runId && context.runIds.has(runId)) return true;
const agentId = readString(payload.agentId);
return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId;
}
function shouldSuppressAgentStatusToastForVisibleIssue(
queryClient: QueryClient,
pathname: string,
payload: Record<string, unknown>,
options?: VisibleRouteOptions,
): boolean {
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
if (!context?.assigneeAgentId) return false;
const agentId = readString(payload.agentId);
return !!agentId && agentId === context.assigneeAgentId;
}
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
@@ -256,7 +385,7 @@ function buildJoinRequestToast(
title: `${label} wants to join`,
body: "A new join request is waiting for approval.",
tone: "info",
action: { label: "View inbox", href: "/inbox/unread" },
action: { label: "View inbox", href: "/inbox/mine" },
dedupeKey: `join-request:${entityId}`,
};
}
@@ -361,6 +490,9 @@ function invalidateActivityQueries(
if (entityType === "issue") {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
if (entityId) {
const details = readRecord(payload.details);
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
@@ -420,6 +552,11 @@ function invalidateActivityQueries(
return;
}
if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") {
queryClient.invalidateQueries({ queryKey: ["routines"] });
return;
}
if (entityType === "company") {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
}
@@ -463,6 +600,7 @@ function gatedPushToast(
function handleLiveEvent(
queryClient: QueryClient,
expectedCompanyId: string,
pathname: string,
event: LiveEvent,
pushToast: (toast: ToastInput) => string | null,
gate: ToastGate,
@@ -480,7 +618,12 @@ function handleLiveEvent(
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
if (event.type === "heartbeat.run.status") {
const toast = buildRunStatusToast(payload, nameOf);
if (toast) gatedPushToast(gate, pushToast, "run-status", toast);
if (
toast &&
!shouldSuppressRunStatusToastForVisibleIssue(queryClient, pathname, payload)
) {
gatedPushToast(gate, pushToast, "run-status", toast);
}
}
return;
}
@@ -496,7 +639,12 @@ function handleLiveEvent(
const agentId = readString(payload.agentId);
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
const toast = buildAgentStatusToast(payload, nameOf, queryClient, expectedCompanyId);
if (toast) gatedPushToast(gate, pushToast, "agent-status", toast);
if (
toast &&
!shouldSuppressAgentStatusToastForVisibleIssue(queryClient, pathname, payload)
) {
gatedPushToast(gate, pushToast, "agent-status", toast);
}
return;
}
@@ -506,15 +654,70 @@ function handleLiveEvent(
const toast =
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
buildJoinRequestToast(payload);
if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
if (
toast &&
!shouldSuppressActivityToastForVisibleIssue(queryClient, pathname, payload)
) {
gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
}
}
}
function resolveLiveCompanyId(
selectedCompanyId: string | null,
selectedCompanyLiveId: string | null,
): string | null {
return selectedCompanyId && selectedCompanyId === selectedCompanyLiveId
? selectedCompanyId
: null;
}
function resetSocketHandlers(target: LiveUpdatesSocketLike) {
target.onopen = null;
target.onmessage = null;
target.onerror = null;
target.onclose = null;
}
function closeSocketQuietly(target: LiveUpdatesSocketLike | null, reason: string) {
if (!target) return;
if (target.readyState === SOCKET_CONNECTING) {
// Let the handshake complete and then close. Calling close() while the
// socket is still CONNECTING is what triggers the noisy browser error.
target.onopen = () => {
resetSocketHandlers(target);
target.close(1000, reason);
};
target.onmessage = null;
target.onerror = () => undefined;
target.onclose = null;
return;
}
resetSocketHandlers(target);
if (target.readyState === SOCKET_OPEN) {
target.close(1000, reason);
}
}
export const __liveUpdatesTestUtils = {
closeSocketQuietly,
invalidateActivityQueries,
resolveLiveCompanyId,
shouldSuppressActivityToastForVisibleIssue,
shouldSuppressRunStatusToastForVisibleIssue,
shouldSuppressAgentStatusToastForVisibleIssue,
};
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const location = useLocation();
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
const pathnameRef = useRef(location.pathname);
const { data: session, status: sessionStatus } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
@@ -522,13 +725,17 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const socketAuthKey = session?.session?.id ?? currentUserId ?? "signed_out";
const liveCompanyId = selectedCompany?.id === selectedCompanyId ? selectedCompanyId : null;
const liveCompanyId = resolveLiveCompanyId(selectedCompanyId, selectedCompany?.id ?? null);
const canConnectSocket = sessionStatus === "success" && session !== null && liveCompanyId !== null;
const currentActorRef = useRef<{ userId: string | null; agentId: string | null }>({
userId: currentUserId,
agentId: null,
});
useEffect(() => {
pathnameRef.current = location.pathname;
}, [location.pathname]);
useEffect(() => {
currentActorRef.current = {
userId: currentUserId,
@@ -543,7 +750,6 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
let reconnectAttempt = 0;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const noop = () => undefined;
const clearReconnect = () => {
if (reconnectTimer !== null) {
@@ -552,35 +758,6 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
}
};
const closeSocketQuietly = (target: WebSocket | null, reason: string) => {
if (!target) return;
if (target.readyState === WebSocket.CONNECTING) {
// Let the handshake complete and then close. Calling close() while the
// socket is still CONNECTING is what triggers the noisy browser error.
target.onopen = () => {
target.onopen = null;
target.onmessage = null;
target.onerror = null;
target.onclose = null;
target.close(1000, reason);
};
target.onmessage = null;
target.onerror = noop;
target.onclose = null;
return;
}
target.onopen = null;
target.onmessage = null;
target.onerror = null;
target.onclose = null;
if (target.readyState === WebSocket.OPEN) {
target.close(1000, reason);
}
};
const scheduleReconnect = () => {
if (closed) return;
reconnectAttempt += 1;
@@ -615,7 +792,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
try {
const parsed = JSON.parse(raw) as LiveEvent;
handleLiveEvent(queryClient, liveCompanyId, parsed, pushToast, gateRef.current, {
handleLiveEvent(queryClient, liveCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, {
userId: currentActorRef.current.userId,
agentId: currentActorRef.current.agentId,
});