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
30 changes: 30 additions & 0 deletions apps/app/src/hooks/cache-owners/system-cache-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,36 @@ export function refetchErroredRealtimeQueriesOnInitialConnect({
});
}

interface InitialConnectInvalidationArgs extends QueryClientArg {
/** Timestamp at which the realtime subscriptions became active. */
connectedAt: number;
}

/**
* A query that resolved before the realtime subscriptions were active may have
* missed change events published in between, and nothing later corrects it:
* the initial-connect path never fires again and staleTime defers mount/focus
* refetches. On desktop cold start this window is real — the host daemon opens
* its session a few hundred ms after the server starts listening, so the first
* hosts/providers fetches capture "disconnected" and the `host-connected`
* broadcast lands before the app's subscription registers. Invalidate any
* realtime query whose data predates the subscription watermark; queries that
* resolve after it observe post-subscribe server state and stay untouched.
*/
export function invalidateRealtimeQueriesFetchedBeforeInitialConnect({
connectedAt,
queryClient,
}: InitialConnectInvalidationArgs): void {
for (const queryKey of getServerReconnectInvalidationQueryKeys()) {
queryClient.invalidateQueries({
queryKey,
predicate: (query) =>
query.state.dataUpdatedAt !== 0 &&
query.state.dataUpdatedAt < connectedAt,
});
}
}

/**
* Refresh `/system/config` after an experiments write: the server broadcast
* covers other windows, this gives the writing window an immediate re-gate.
Expand Down
7 changes: 7 additions & 0 deletions apps/app/src/hooks/realtime-cache-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "@bb/domain";
import {
invalidateRealtimeQueriesAfterServerReconnect,
invalidateRealtimeQueriesFetchedBeforeInitialConnect,
refetchErroredRealtimeQueriesOnInitialConnect,
} from "./cache-owners/system-cache-effects";
import { createBufferedEnvironmentInvalidator } from "./buffered-environment-invalidator";
Expand Down Expand Up @@ -308,6 +309,12 @@ export function createRealtimeCacheEffects({
return;
}
refetchErroredRealtimeQueriesOnInitialConnect({ queryClient });
// The ws manager flushes subscribe messages before this callback runs,
// so "now" is the watermark after which change events are delivered.
invalidateRealtimeQueriesFetchedBeforeInitialConnect({
connectedAt: Date.now(),
queryClient,
});
},
};
}
37 changes: 36 additions & 1 deletion apps/app/src/hooks/system-cache-effects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { createAppQueryClient } from "@/lib/query-client";
import {
environmentDiffFilesQueryKey,
environmentDiffPatchQueryKey,
hostsQueryKey,
sidebarNavigationQueryKey,
systemProvidersQueryKey,
systemExecutionOptionsQueryKey,
terminalsQueryKey,
threadConversationOutlineQueryKey,
Expand All @@ -19,7 +21,10 @@ import {
threadSearchQueryKey,
threadTimelineQueryKey,
} from "./queries/query-keys";
import { invalidateRealtimeQueriesAfterServerReconnect } from "./cache-owners/system-cache-effects";
import {
invalidateRealtimeQueriesAfterServerReconnect,
invalidateRealtimeQueriesFetchedBeforeInitialConnect,
} from "./cache-owners/system-cache-effects";

function createCacheEffectQueryClient() {
return createAppQueryClient({
Expand Down Expand Up @@ -219,6 +224,36 @@ describe("system cache effects", () => {
queryClient.clear();
});

// Desktop cold start: the host daemon opens its session a few hundred ms
// after the server starts listening, so hosts/providers fetched in that
// window miss the `host-connected` broadcast and would otherwise show
// "Host is offline" (and no models) until something remounts the composer.
it("invalidates realtime queries whose data predates the initial connect", () => {
const queryClient = createCacheEffectQueryClient();
const hostsKey = hostsQueryKey();
const providersKey = systemProvidersQueryKey();
const neverFetchedKey = sidebarNavigationQueryKey();
const connectedAt = Date.now();
queryClient.setQueryData(hostsKey, [], { updatedAt: connectedAt - 500 });
queryClient.setQueryData(
providersKey,
{ providers: [] },
{ updatedAt: connectedAt + 500 },
);

invalidateRealtimeQueriesFetchedBeforeInitialConnect({
connectedAt,
queryClient,
});

// Fetched before the subscriptions were active: may have missed events.
expect(queryClient.getQueryState(hostsKey)?.isInvalidated).toBe(true);
// Fetched after the watermark: observed post-subscribe server state.
expect(queryClient.getQueryState(providersKey)?.isInvalidated).toBe(false);
// Never fetched: nothing stale to correct.
expect(queryClient.getQueryState(neverFetchedKey)).toBeUndefined();
});

it("refetches an active diff TOC query but evicts the observer-less patch cache after reconnect", async () => {
const queryClient = createCacheEffectQueryClient();
const diffFilesKey = environmentDiffFilesQueryKey("env-1", "all", "main");
Expand Down
Loading