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
14 changes: 14 additions & 0 deletions apps/app/src/hooks/cache-owners/cache-effect-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { QueryClient, QueryKey, Updater } from "@tanstack/react-query";
import type { QueryKeysArg } from "../cache-effect-types";

export function invalidateQueryKeys({
Expand All @@ -24,3 +25,16 @@ export function refetchFailedActiveQueryKeys({
.catch(() => {});
}
}

export function patchCachedQueryData<T>(
queryClient: QueryClient,
queryKey: QueryKey,
updater: Updater<T | undefined, T | undefined>,
): void {
const fetchedAt = queryClient.getQueryState<T>(queryKey)?.dataUpdatedAt;
queryClient.setQueryData<T>(
queryKey,
updater,
fetchedAt ? { updatedAt: fetchedAt } : undefined,
);
}
7 changes: 5 additions & 2 deletions apps/app/src/hooks/cache-owners/project-cache-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
sidebarNavigationQueryKey,
} from "../queries/query-keys";
import { invalidateProjectDeleteQueries } from "./mutation-cache-effects";
import { patchCachedQueryData } from "./cache-effect-utils";

interface ApplyProjectCreateResultArgs {
project: ProjectResponse;
Expand Down Expand Up @@ -115,7 +116,8 @@ export function applyProjectCreateResult({
? applyProjectToProjectList(currentProjects, project)
: [project],
);
queryClient.setQueryData<SidebarBootstrapResponse>(
patchCachedQueryData<SidebarBootstrapResponse>(
queryClient,
sidebarNavigationQueryKey(),
(currentNavigation) =>
currentNavigation
Expand All @@ -135,7 +137,8 @@ export function applyProjectDeleteResult({
? removeProjectFromProjectList(currentProjects, projectId)
: currentProjects,
);
queryClient.setQueryData<SidebarBootstrapResponse>(
patchCachedQueryData<SidebarBootstrapResponse>(
queryClient,
sidebarNavigationQueryKey(),
(currentNavigation) =>
currentNavigation
Expand Down
17 changes: 12 additions & 5 deletions apps/app/src/hooks/cache-owners/query-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
iterateThreadListCacheEntries,
} from "./thread-list-cache-data";
import { bumpDiffPatchEvictionGeneration } from "./environment-diff-patch-cache-owner";
import { patchCachedQueryData } from "./cache-effect-utils";
import { readCachedSidebarBootstrap } from "@/lib/sidebar-bootstrap-cache";
import type {
SidebarBootstrapResponse,
Expand Down Expand Up @@ -310,7 +311,8 @@ export function applyToCachedSidebarNavigationThreads({
mapper,
queryClient,
}: ApplyToCachedSidebarNavigationThreadsArgs): void {
queryClient.setQueryData<SidebarBootstrapResponse>(
patchCachedQueryData<SidebarBootstrapResponse>(
queryClient,
sidebarNavigationQueryKey(),
(currentNavigation) => {
if (!currentNavigation) {
Expand Down Expand Up @@ -398,7 +400,7 @@ export function restoreCachedSidebarNavigation(
queryClient: QueryClient,
snapshot: CachedSidebarNavigationSnapshot,
): void {
queryClient.setQueryData(sidebarNavigationQueryKey(), snapshot);
patchCachedQueryData(queryClient, sidebarNavigationQueryKey(), snapshot);
}

export function getEnvironmentRecordInvalidationQueryKeys({
Expand Down Expand Up @@ -627,14 +629,19 @@ export function optimisticallyInsertThread(
continue;
}

queryClient.setQueryData<ThreadListEntry[]>(queryKey, upsertThread(data));
patchCachedQueryData<ThreadListEntry[]>(
queryClient,
queryKey,
upsertThread(data),
);
}

if (thread.visibility === "hidden" || thread.archivedAt !== null) {
return;
}

queryClient.setQueryData<SidebarBootstrapResponse>(
patchCachedQueryData<SidebarBootstrapResponse>(
queryClient,
sidebarNavigationQueryKey(),
(navigation) => {
if (!navigation) {
Expand Down Expand Up @@ -680,7 +687,7 @@ function updateCachedTimelineRows({
continue;
}

queryClient.setQueryData<ThreadTimelineResponse>(queryKey, {
patchCachedQueryData<ThreadTimelineResponse>(queryClient, queryKey, {
...response,
rows: [...nextRows],
});
Expand Down
6 changes: 4 additions & 2 deletions apps/app/src/hooks/cache-owners/thread-list-cache-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
QueryKey,
} from "@tanstack/react-query";
import type { ThreadListEntry } from "@bb/domain";
import { patchCachedQueryData } from "./cache-effect-utils";

export type ThreadListCacheData =
| ThreadListEntry[]
Expand Down Expand Up @@ -93,7 +94,7 @@ export function restoreCachedThreadLists(
snapshot: CachedThreadListSnapshot,
): void {
for (const { queryKey, data } of snapshot) {
queryClient.setQueryData(queryKey, data);
patchCachedQueryData(queryClient, queryKey, data);
}
}

Expand All @@ -104,7 +105,8 @@ export function applyToCachedThreadLists(
for (const { queryKey, data } of getCachedThreadLists(queryClient, {
queryKey: options.queryKey,
})) {
queryClient.setQueryData(
patchCachedQueryData(
queryClient,
queryKey,
mapThreadListCacheData(data, options.mapper),
);
Expand Down
60 changes: 60 additions & 0 deletions apps/app/src/hooks/realtime-cache-effects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3010,6 +3010,66 @@ describe("createRealtimeCacheEffects", () => {
effects.dispose();
});

it("still resyncs the sidebar when a hidden flush precedes the reconnect", () => {
vi.useFakeTimers();
const visibility = createFakeVisibility();
const { effects, queryClient } =
createRealtimeEffectsTestContext(visibility);
const sidebarNavigationKey = sidebarNavigationQueryKey();
queryClient.setQueryData(sidebarNavigationKey, {
projects: [
{
threads: [
{
activity: NO_THREAD_ACTIVITY,
id: "thr_1",
latestAttentionAt: 100,
runtime: {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
},
status: "idle",
updatedAt: 100,
},
],
},
],
personalProject: { threads: [] },
});
vi.advanceTimersByTime(1000);
const disconnectedAt = Date.now();

visibility.setVisible(false);
vi.advanceTimersByTime(1000);
effects.handleChanged({
type: "changed",
entity: "thread",
id: "thr_1",
metadata: {
projectId: "project-1",
statusChange: {
activity: NO_THREAD_ACTIVITY,
latestAttentionAt: 200,
runtime: {
displayStatus: "active",
hostReconnectGraceExpiresAt: null,
},
status: "active",
updatedAt: 200,
},
},
changes: ["status-changed"],
});
vi.advanceTimersByTime(60_000);
visibility.setVisible(true);
effects.handleConnected({ reconnected: true, disconnectedAt });

expect(
queryClient.getQueryState(sidebarNavigationKey)?.isInvalidated,
).toBe(true);
effects.dispose();
});

it("holds a debounce that elapses hidden and non-thread changes until visible", async () => {
vi.useFakeTimers();
const visibility = createFakeVisibility();
Expand Down
44 changes: 38 additions & 6 deletions apps/app/src/lib/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,12 +400,7 @@ describe("WebSocketManager liveness", () => {

vi.advanceTimersByTime(REALTIME_PING_INTERVAL_MS);
expect(pingCount(socket)).toBe(1);
receive(socket, {
type: "changed",
entity: "thread",
id: "thr_1",
changes: ["events-appended"],
});
receive(socket, { type: "pong" });
vi.advanceTimersByTime(REALTIME_PONG_TIMEOUT_MS);
expect(fakeSocketState.instances).toHaveLength(1);
});
Expand All @@ -424,6 +419,43 @@ describe("WebSocketManager liveness", () => {
expect(pingCount(socket)).toBe(2);
});

it("probes on becoming visible even when buffered frames land on resume", () => {
const { browserEvents, socket } = createLiveManager();

browserEvents.setVisible(false);
vi.advanceTimersByTime(REALTIME_PING_INTERVAL_MS * 4);
receive(socket, {
type: "changed",
entity: "thread",
id: "thr_1",
changes: ["events-appended"],
});
browserEvents.setVisible(true);

expect(pingCount(socket)).toBe(1);
vi.advanceTimersByTime(REALTIME_PONG_TIMEOUT_MS);
expect(fakeSocketState.instances).toHaveLength(2);
});

it("replaces the socket when a stale frame arrives instead of a pong", () => {
const { browserEvents, socket } = createLiveManager();

browserEvents.setVisible(false);
vi.advanceTimersByTime(REALTIME_PING_INTERVAL_MS * 4);
browserEvents.setVisible(true);
expect(pingCount(socket)).toBe(1);

receive(socket, {
type: "changed",
entity: "thread",
id: "thr_1",
changes: ["events-appended"],
});
vi.advanceTimersByTime(REALTIME_PONG_TIMEOUT_MS);

expect(fakeSocketState.instances).toHaveLength(2);
});

it("reconnects immediately on visible or online when the socket is closed", () => {
const { browserEvents, connectedEvents, socket } = createLiveManager();

Expand Down
11 changes: 7 additions & 4 deletions apps/app/src/lib/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export class WebSocketManager {
}
switch (this.socket.readyState) {
case WebSocket.OPEN:
this.sendPing();
this.sendPing({ ignoreRecentActivity: true });
return;
case WebSocket.CONNECTING:
return;
Expand Down Expand Up @@ -230,11 +230,14 @@ export class WebSocketManager {
this.clearPongTimer();
}

private sendPing(): void {
private sendPing(options?: { ignoreRecentActivity: boolean }): void {
if (this.socket?.readyState !== WebSocket.OPEN) {
return;
}
if (Date.now() - this.lastServerActivityAt < REALTIME_PONG_TIMEOUT_MS) {
if (
!options?.ignoreRecentActivity &&
Date.now() - this.lastServerActivityAt < REALTIME_PONG_TIMEOUT_MS
) {
return;
}
this.sendMessage({ type: "ping" });
Expand All @@ -256,7 +259,6 @@ export class WebSocketManager {

private noteServerActivity(): void {
this.lastServerActivityAt = Date.now();
this.clearPongTimer();
}

private markSocketLost(at: number): void {
Expand All @@ -276,6 +278,7 @@ export class WebSocketManager {
}

if (pongMessageLenientSchema.safeParse(parsed).success) {
this.clearPongTimer();
return;
}

Expand Down
Loading