Skip to content

Commit df3a705

Browse files
committed
Replace the conversation cache with a one-shot create-to-load handoff
1 parent dd75ebc commit df3a705

5 files changed

Lines changed: 71 additions & 163 deletions

File tree

src/lib/utils/conversationCache.ts

Lines changed: 0 additions & 73 deletions
This file was deleted.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { browser } from "$app/environment";
2+
import type { Message } from "$lib/types/Message";
3+
import type { DeployedSpace } from "$lib/types/Conversation";
4+
5+
// Payload shape of GET /api/v2/conversations/[id] (post superjson-parse),
6+
// shared by the page load and the create-conversation seed.
7+
export interface ConversationData {
8+
messages: Message[];
9+
title: string;
10+
model: string;
11+
preprompt?: string;
12+
rootMessageId?: string;
13+
id: string;
14+
updatedAt: Date;
15+
modelId: string;
16+
shared: boolean;
17+
deployedSpaces?: Record<string, DeployedSpace>;
18+
}
19+
20+
// One-shot handoff of the conversation payload embedded in the create
21+
// response: POST /conversation seeds it, and the very next load of that
22+
// conversation consumes (and deletes) it, skipping the GET that otherwise
23+
// sits between conversation creation and the first generation request.
24+
//
25+
// Deliberately NOT a cache: an entry lives for the milliseconds between
26+
// goto() and the page load, is read at most once, and every later load of
27+
// the conversation always fetches fresh data. That keeps this immune to the
28+
// staleness classes a real cache has to manage (deletes from other tabs,
29+
// invalidation ordering, session changes).
30+
//
31+
// Browser-only: module state on the server is shared across requests.
32+
const pending = new Map<string, ConversationData>();
33+
34+
// An entry is only orphaned if the goto() after create never happens (e.g.
35+
// navigation error); keep the map bounded anyway.
36+
const MAX_ENTRIES = 2;
37+
38+
export function seedPendingConversation(id: string, data: ConversationData): void {
39+
if (!browser) return;
40+
pending.set(id, data);
41+
if (pending.size > MAX_ENTRIES) {
42+
const oldest = pending.keys().next().value;
43+
if (oldest !== undefined) pending.delete(oldest);
44+
}
45+
}
46+
47+
/** Returns the seeded payload at most once, deleting it on read. */
48+
export function takePendingConversation(id: string): ConversationData | undefined {
49+
if (!browser) return undefined;
50+
const data = pending.get(id);
51+
pending.delete(id);
52+
return data;
53+
}

src/routes/+layout.svelte

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
import BackgroundGenerationPoller from "$lib/components/BackgroundGenerationPoller.svelte";
2626
import { requireAuthUser } from "$lib/utils/auth";
2727
import { createConversationsStore } from "$lib/stores/conversations.svelte";
28-
import { invalidateCachedConversation } from "$lib/utils/conversationCache";
2928
3029
let { data = $bindable(), children } = $props();
3130
@@ -74,9 +73,6 @@
7473
.then(handleResponse)
7574
.then(async () => {
7675
convsStore.remove(id);
77-
// Drop any cached copy so a back-navigation to the deleted
78-
// conversation cannot render it from cache.
79-
invalidateCachedConversation(id);
8076
8177
if (page.params.id === id) {
8278
await goto(`${base}/`, { invalidateAll: true });

src/routes/+page.svelte

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import { ERROR_MESSAGES, error } from "$lib/stores/errors";
1111
import { storePendingFiles } from "$lib/utils/pendingFiles";
1212
import superjson from "superjson";
13-
import { setCachedConversation, type ConversationData } from "$lib/utils/conversationCache";
13+
import { seedPendingConversation, type ConversationData } from "$lib/utils/pendingConversation";
1414
import { useSettingsStore } from "$lib/stores/settings.js";
1515
import { useConversationsStore } from "$lib/stores/conversations.svelte";
1616
import { findCurrentModel } from "$lib/utils/models";
@@ -78,12 +78,12 @@
7878
7979
const { conversationId, conversation } = await res.json();
8080
81-
// The create response embeds the conversation payload; seed the client
82-
// cache with it so the conversation page load skips its GET and the
83-
// first generation request starts one round-trip sooner.
81+
// The create response embeds the conversation payload; hand it to the
82+
// conversation page as a one-shot seed so its load skips the GET and
83+
// the first generation request starts one round-trip sooner.
8484
if (typeof conversation === "string") {
8585
try {
86-
setCachedConversation(conversationId, superjson.parse<ConversationData>(conversation));
86+
seedPendingConversation(conversationId, superjson.parse<ConversationData>(conversation));
8787
} catch {
8888
// Malformed seed: the page load falls back to a normal fetch.
8989
}

src/routes/conversation/[id]/+page.ts

Lines changed: 13 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -3,72 +3,8 @@ import { UrlDependency } from "$lib/types/UrlDependency";
33
import { redirect } from "@sveltejs/kit";
44
import { base } from "$app/paths";
55
import { browser } from "$app/environment";
6-
import { safeInvalidate } from "$lib/utils/safeInvalidate";
76
import type { PageLoad } from "./$types";
8-
import {
9-
CONVERSATION_CACHE_FRESH_MS,
10-
conversationFingerprint,
11-
getCachedConversation,
12-
invalidateCachedConversation,
13-
setCachedConversation,
14-
type ConversationData,
15-
} from "$lib/utils/conversationCache";
16-
17-
// Id of the conversation the previous load() call served. A repeat load for
18-
// the same id means invalidate(UrlDependency.Conversation) ran (stream
19-
// finished, model switch, background poller) and fresh data is expected; only
20-
// a load for a different id is a navigation that may be served from cache.
21-
let lastLoadedId: string | undefined;
22-
23-
async function fetchConversation(
24-
client: ReturnType<typeof useAPIClient>,
25-
id: string,
26-
fromShare: string | undefined
27-
): Promise<ConversationData> {
28-
return (await client
29-
.conversations({ id })
30-
.get({ query: { fromShare } })
31-
.then(handleResponse)) as ConversationData;
32-
}
33-
34-
// Serve-stale-then-revalidate: fetch fresh data off the critical path and, if
35-
// it differs from what the cache (and thus the page) currently shows, refresh
36-
// the cache and re-run the load — which then hits the updated, fresh entry.
37-
function revalidateInBackground(
38-
client: ReturnType<typeof useAPIClient>,
39-
id: string,
40-
fromShare: string | undefined,
41-
served: ConversationData
42-
) {
43-
void client
44-
.conversations({ id })
45-
.get({ query: { fromShare } })
46-
.then((response) => {
47-
// The conversation is gone or this session can no longer read it
48-
// (deleted in another tab, revoked access, expired session): drop the
49-
// entry and re-run the load so the page takes its real error path
50-
// (redirect home) instead of keeping the stale view on screen.
51-
if (response.status === 401 || response.status === 403 || response.status === 404) {
52-
invalidateCachedConversation(id);
53-
return safeInvalidate(UrlDependency.Conversation);
54-
}
55-
// Throws on remaining errors (5xx etc.), handled as transient below.
56-
const fresh = handleResponse(response) as ConversationData;
57-
if (conversationFingerprint(fresh) === conversationFingerprint(served)) return;
58-
setCachedConversation(id, fresh);
59-
// safeInvalidate, not invalidate: a raw invalidate() fired from this
60-
// background task can cancel an in-flight navigation the user just
61-
// started (see safeInvalidate.ts).
62-
return safeInvalidate(UrlDependency.Conversation);
63-
})
64-
.catch(() => {
65-
// Transient failure (network blip, server error): only drop the cache
66-
// entry; the next real navigation refetches and takes the genuine
67-
// error path. Forcing a reload here would bounce the user to the
68-
// homepage on a mere network hiccup.
69-
invalidateCachedConversation(id);
70-
});
71-
}
7+
import { takePendingConversation, type ConversationData } from "$lib/utils/pendingConversation";
728

739
export const load: PageLoad = async ({ params, depends, fetch, url, parent }) => {
7410
depends(UrlDependency.Conversation);
@@ -102,28 +38,24 @@ export const load: PageLoad = async ({ params, depends, fetch, url, parent }) =>
10238
}
10339

10440
const fromShare = url.searchParams.get("fromShare") ?? undefined;
105-
const isNavigation = lastLoadedId !== params.id;
106-
lastLoadedId = params.id;
10741

108-
// Share views bypass the cache entirely: their payload depends on the
109-
// fromShare parameter, not just the conversation id.
110-
if (browser && isNavigation && !fromShare) {
111-
const cached = getCachedConversation(params.id);
112-
if (cached) {
113-
if (Date.now() - cached.fetchedAt > CONVERSATION_CACHE_FRESH_MS) {
114-
revalidateInBackground(client, params.id, fromShare, cached.data);
115-
}
116-
return cached.data;
42+
// A conversation created by this tab hands its payload over via a one-shot
43+
// seed (see pendingConversation.ts), consumed here so the first load after
44+
// create skips the network round trip. Every other load, including every
45+
// invalidate() re-run, fetches fresh data.
46+
if (browser && !fromShare) {
47+
const seeded = takePendingConversation(params.id);
48+
if (seeded) {
49+
return seeded;
11750
}
11851
}
11952

12053
// Load conversation (works for both owned and shared conversations)
12154
try {
122-
const data = await fetchConversation(client, params.id, fromShare);
123-
if (browser && !fromShare) {
124-
setCachedConversation(params.id, data);
125-
}
126-
return data;
55+
return (await client
56+
.conversations({ id: params.id })
57+
.get({ query: { fromShare } })
58+
.then(handleResponse)) as ConversationData;
12759
} catch {
12860
redirect(302, `${base}/`);
12961
}

0 commit comments

Comments
 (0)