Keep active previews and pagination requests alive

This commit is contained in:
Mohamed Boudra
2026-07-28 16:45:40 +02:00
parent 012100b4a8
commit e53dcf2a6c
5 changed files with 195 additions and 58 deletions
@@ -428,6 +428,60 @@ describe("createWebStreamStrategy", () => {
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
});
it("loads older history when the current page projects no anchor row", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn().mockReturnValue(true);
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: false,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(strategy.render(renderInput));
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 1000 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
await Promise.resolve();
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
});
it("anchors prepended history independently from live content growth below it", async () => {
let anchorTop = 0;
let measuredScrollTop = 0;
@@ -256,15 +256,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1);
const anchorElement =
contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null;
if (!scrollContainer || !anchorRow || !anchorElement) {
return;
if (scrollContainer && anchorRow && anchorElement) {
historyStartPrependAnchorRef.current = {
progressKey: olderHistoryProgressKey,
rowId: anchorRow.id,
viewportOffset:
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
};
} else {
historyStartPrependAnchorRef.current = null;
}
historyStartPrependAnchorRef.current = {
progressKey: olderHistoryProgressKey,
rowId: anchorRow.id,
viewportOffset:
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
};
historyStartPrependAnchorActiveRef.current = false;
const requestedProgressKey = olderHistoryProgressKey;
void (async () => {
@@ -140,4 +140,26 @@ describe("assistant image acquisition cache", () => {
released: ["attachment-1"],
});
});
it("does not evict a value while an active consumer retains it", async () => {
const released: string[] = [];
const cache = createAssistantImageAcquisitionCache<string>({
capacity: 1,
onRetain: (value) => () => released.push(value),
});
const first = cache.acquireRetained("first", async () => "attachment-1");
await first.promise;
const second = cache.acquireRetained("second", async () => "attachment-2");
await second.promise;
expect({ released, size: cache.size() }).toEqual({ released: [], size: 2 });
first.release();
expect({ released, size: cache.size() }).toEqual({
released: ["attachment-1"],
size: 1,
});
second.release();
});
});
@@ -2,6 +2,10 @@ import { createPreviewAttachmentId } from "@/attachments/utils";
export interface AssistantImageAcquisitionCache<T> {
acquire(key: string, locate: () => Promise<T>): Promise<T>;
acquireRetained(
key: string,
locate: () => Promise<T>,
): { promise: Promise<T>; value?: T; release: () => void };
peek(key: string): T | undefined;
size(): number;
}
@@ -53,6 +57,7 @@ export function createAssistantImageAcquisitionCache<T>(input: {
resolved: boolean;
value?: T;
release: (() => void) | null;
activeConsumers: number;
}
const entries = new Map<string, CacheEntry>();
@@ -64,42 +69,79 @@ export function createAssistantImageAcquisitionCache<T>(input: {
entry.release = null;
};
const enforceCapacity = () => {
while (entries.size > input.capacity) {
let evicted = false;
for (const [key, entry] of entries) {
if (entry.activeConsumers > 0) {
continue;
}
evict(key, entry);
evicted = true;
break;
}
if (!evicted) {
return;
}
}
};
const acquireEntry = (key: string, locate: () => Promise<T>, retain: boolean): CacheEntry => {
const cached = entries.get(key);
if (cached) {
entries.delete(key);
entries.set(key, cached);
if (retain) {
cached.activeConsumers += 1;
}
return cached;
}
const pending = locate();
const entry: CacheEntry = {
pending,
resolved: false,
release: null,
activeConsumers: retain ? 1 : 0,
};
entries.set(key, entry);
enforceCapacity();
void (async () => {
try {
const value = await pending;
const release = input.onRetain?.(value) ?? null;
if (entries.get(key) === entry) {
entry.value = value;
entry.resolved = true;
entry.release = release;
} else {
release?.();
}
} catch {
evict(key, entry);
}
})();
return entry;
};
return {
acquire(key, locate) {
const cached = entries.get(key);
if (cached) {
entries.delete(key);
entries.set(key, cached);
return cached.pending;
}
const pending = locate();
const entry: CacheEntry = { pending, resolved: false, release: null };
entries.set(key, entry);
if (entries.size > input.capacity) {
const leastRecentlyUsedKey = entries.keys().next().value;
if (leastRecentlyUsedKey !== undefined) {
const leastRecentlyUsedEntry = entries.get(leastRecentlyUsedKey);
if (leastRecentlyUsedEntry) {
evict(leastRecentlyUsedKey, leastRecentlyUsedEntry);
return acquireEntry(key, locate, false).pending;
},
acquireRetained(key, locate) {
const entry = acquireEntry(key, locate, true);
let released = false;
return {
promise: entry.pending,
...(entry.resolved ? { value: entry.value } : {}),
release() {
if (released) {
return;
}
}
}
void (async () => {
try {
const value = await pending;
const release = input.onRetain?.(value) ?? null;
if (entries.get(key) === entry) {
entry.value = value;
entry.resolved = true;
entry.release = release;
} else {
release?.();
}
} catch {
evict(key, entry);
}
})();
return pending;
released = true;
entry.activeConsumers = Math.max(0, entry.activeConsumers - 1);
enforceCapacity();
},
};
},
peek(key) {
const entry = entries.get(key);
@@ -1,4 +1,12 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import type { ImageLoadEvent } from "react-native";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -301,16 +309,16 @@ function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): Previ
? `${id}:${storageType}:${storageKey}:${mimeType}`
: null;
const [entry, setEntry] = useState<{ key: string | null; state: PreviewUrlState }>(() => {
const cached = previewKey ? previewUrlCache.peek(previewKey) : undefined;
return {
key: previewKey,
state: cached ? { status: "loaded", uri: cached.uri } : { status: "waiting" },
state: { status: "waiting" },
};
});
const getCurrentAttachment = useStableEvent(() => attachment ?? null);
useEffect(() => {
useLayoutEffect(() => {
let disposed = false;
let releaseCurrent: (() => void) | null = null;
const current = getCurrentAttachment();
if (!current || !previewKey) {
@@ -318,21 +326,34 @@ function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): Previ
return;
}
const cached = previewUrlCache.peek(previewKey);
if (cached) {
setEntry({ key: previewKey, state: { status: "loaded", uri: cached.uri } });
return;
const acquireCurrent = () => {
releaseCurrent?.();
const retained = previewUrlCache.acquireRetained(previewKey, async () => ({
attachment: current,
uri: await resolveAttachmentPreviewUrl(current),
}));
releaseCurrent = retained.release;
return retained;
};
const initial = acquireCurrent();
if (initial.value) {
setEntry({ key: previewKey, state: { status: "loaded", uri: initial.value.uri } });
return () => {
disposed = true;
releaseCurrent?.();
};
}
setEntry({ key: previewKey, state: { status: "loading" } });
void (async () => {
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
try {
const preview = await runAssistantImageOperationWithRetry({
operation: async () =>
await previewUrlCache.acquire(previewKey, async () => ({
attachment: current,
uri: await resolveAttachmentPreviewUrl(current),
})),
operation: async () => {
const retained = firstAttempt ?? acquireCurrent();
firstAttempt = null;
return await retained.promise;
},
shouldStop: () => disposed,
});
if (!disposed) {
@@ -347,16 +368,13 @@ function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): Previ
return () => {
disposed = true;
releaseCurrent?.();
};
}, [getCurrentAttachment, previewKey]);
if (!previewKey) {
return { status: "waiting" };
}
const cached = previewUrlCache.peek(previewKey);
if (cached) {
return { status: "loaded", uri: cached.uri };
}
return entry.key === previewKey ? entry.state : { status: "waiting" };
}