mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
fix(app): stabilize history pagination edges
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
expectSameOlderHistoryLoadingOperation,
|
||||
expectTimelineAtHistoryStart,
|
||||
expectTimelinePromptNotMounted,
|
||||
expectTimelinePromptPositionPreserved,
|
||||
expectTimelinePromptVisible,
|
||||
holdBootstrapTimelinePage,
|
||||
holdDaemonHydration,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
openAgentTimeline,
|
||||
rememberOlderHistoryLoadingOperation,
|
||||
rememberTimelineViewport,
|
||||
rememberTimelinePromptPosition,
|
||||
reloadAgentTimelineFromPersistedReplica,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
@@ -59,6 +61,31 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps visible history anchored when live output grows during a prepend", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
|
||||
|
||||
await agent.client.sendAgentMessage(
|
||||
agent.agentId,
|
||||
"timeline live during held older page: emit 20 coalesced agent stream updates",
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
history.releasePage(1);
|
||||
|
||||
await expectTimelinePromptPositionPreserved(page, position);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("continues one loading operation while older pages still leave history start exposed", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ interface LongTimelineAgentOptions {
|
||||
}
|
||||
|
||||
interface LongTimelineAgent extends MockAgentWorkspace {
|
||||
initialTailOldestPrompt: string;
|
||||
oldestPrompt: string;
|
||||
newestPrompt: string;
|
||||
}
|
||||
@@ -30,6 +31,11 @@ interface TimelineViewportSnapshot {
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
interface TimelinePromptPositionSnapshot {
|
||||
prompt: string;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface OlderHistoryLoadingOperation {
|
||||
animationStartTime: number | null;
|
||||
marker: string;
|
||||
@@ -61,11 +67,41 @@ export async function seedLongMockAgentTimeline(
|
||||
|
||||
return {
|
||||
...agent,
|
||||
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
|
||||
oldestPrompt: promptForTurn(0),
|
||||
newestPrompt: promptForTurn(options.turns - 1),
|
||||
};
|
||||
}
|
||||
|
||||
export async function rememberTimelinePromptPosition(
|
||||
page: Page,
|
||||
prompt: string,
|
||||
): Promise<TimelinePromptPositionSnapshot> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const item = timeline.getByText(prompt, { exact: true });
|
||||
await expect(item).toBeVisible();
|
||||
const box = await item.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Expected a rendered timeline item for ${prompt}`);
|
||||
}
|
||||
return { prompt, top: box.y };
|
||||
}
|
||||
|
||||
export async function expectTimelinePromptPositionPreserved(
|
||||
page: Page,
|
||||
before: TimelinePromptPositionSnapshot,
|
||||
): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const item = timeline.getByText(before.prompt, { exact: true });
|
||||
await expect(item).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await item.boundingBox();
|
||||
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
|
||||
})
|
||||
.toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
|
||||
await page.waitForURL(
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
} from "./history-start-pagination";
|
||||
@@ -111,6 +112,25 @@ describe("history start pagination", () => {
|
||||
expect(away.state).toEqual({ status: "ready" });
|
||||
});
|
||||
|
||||
it("allows another user attempt after a request finishes without progress", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
|
||||
const retried = evaluateHistoryStartPagination(
|
||||
rearmHistoryStartPagination(failed.state),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect(failed.state).toEqual({ status: "latched" });
|
||||
expect(retried.shouldLoad).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mistake repeated edge observations for a finished request", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
|
||||
@@ -27,6 +27,12 @@ export function isHistoryStartLoadingOperation(state: HistoryStartPaginationStat
|
||||
return state.status === "loading" || state.status === "settling";
|
||||
}
|
||||
|
||||
export function rearmHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
): HistoryStartPaginationState {
|
||||
return state.status === "latched" ? { status: "ready" } : state;
|
||||
}
|
||||
|
||||
export function evaluateHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: HistoryStartPaginationInput,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
@@ -430,6 +431,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
isUserScrollActiveRef.current = true;
|
||||
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
||||
bottomAnchorController.beginUserScroll();
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
if (rearmed !== historyStartPaginationStateRef.current) {
|
||||
historyStartPaginationStateRef.current = rearmed;
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
});
|
||||
|
||||
// Defer drag end so momentum can take ownership, but capture the terminal
|
||||
|
||||
@@ -370,6 +370,179 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(onNearHistoryStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries a latched history request when the user scrolls up in a short timeline", async () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const onNearHistoryStart = vi.fn();
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
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));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -120 }));
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
root?.render(strategy.render({ ...renderInput, isLoadingOlderHistory: true }));
|
||||
});
|
||||
act(() => {
|
||||
root?.render(strategy.render(renderInput));
|
||||
});
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -120 }));
|
||||
});
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("anchors prepended history independently from live content growth below it", async () => {
|
||||
let anchorTop = 0;
|
||||
let measuredScrollTop = 0;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
const top = this.dataset.historyRowId === "message-79" ? anchorTop - measuredScrollTop : 0;
|
||||
return {
|
||||
x: 0,
|
||||
y: top,
|
||||
top,
|
||||
right: 0,
|
||||
bottom: top + 24,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 24,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
},
|
||||
);
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const onNearHistoryStart = vi.fn();
|
||||
const initialHistory = Array.from({ length: 20 }, (_, index) => userMessage(index + 60));
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: { historyVirtualized: [], historyMounted: initialHistory, liveHead: [] },
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch-1:60",
|
||||
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");
|
||||
}
|
||||
let scrollHeight = 1000;
|
||||
let scrollTop = 0;
|
||||
Object.defineProperties(scrollContainer, {
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
scrollHeight: { configurable: true, get: () => scrollHeight },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => {
|
||||
scrollTop = value;
|
||||
measuredScrollTop = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -120 }));
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
anchorTop = 600;
|
||||
scrollHeight = 1700;
|
||||
const prependedHistory = Array.from({ length: 40 }, (_, index) => userMessage(index + 40));
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [...prependedHistory, ...initialHistory],
|
||||
liveHead: [userMessage(80)],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: true,
|
||||
},
|
||||
isLoadingOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch-1:40",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTop).toBe(600);
|
||||
});
|
||||
|
||||
it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => {
|
||||
const scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
@@ -31,8 +32,8 @@ interface CreateWebStreamStrategyInput {
|
||||
|
||||
interface HistoryStartPrependAnchor {
|
||||
progressKey: string;
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
rowId: string;
|
||||
viewportOffset: number;
|
||||
}
|
||||
|
||||
type ScrollBehaviorLike = "auto" | "smooth";
|
||||
@@ -49,6 +50,15 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
function findHistoryRowElement(contentNode: HTMLElement, rowId: string): HTMLElement | null {
|
||||
for (const element of contentNode.querySelectorAll<HTMLElement>("[data-history-row-id]")) {
|
||||
if (element.dataset.historyRowId === rowId) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const historyStartSlotStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -236,13 +246,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
const contentNode = contentRef.current;
|
||||
const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1);
|
||||
const anchorElement =
|
||||
contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null;
|
||||
if (!scrollContainer || !anchorRow || !anchorElement) {
|
||||
return;
|
||||
}
|
||||
historyStartPrependAnchorRef.current = {
|
||||
progressKey: olderHistoryProgressKey,
|
||||
scrollHeight: scrollContainer.scrollHeight,
|
||||
scrollTop: scrollContainer.scrollTop,
|
||||
rowId: anchorRow.id,
|
||||
viewportOffset:
|
||||
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
};
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
onNearHistoryStart();
|
||||
@@ -259,14 +274,34 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
});
|
||||
const applyHistoryStartPrependAnchor = useStableEvent(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const anchor = historyStartPrependAnchorRef.current;
|
||||
if (!scrollContainer || !anchor || !historyStartPrependAnchorActiveRef.current) {
|
||||
const rearmHistoryStartFromUserIntent = useStableEvent(() => {
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
if (rearmed === historyStartPaginationStateRef.current) {
|
||||
return;
|
||||
}
|
||||
const contentGrowth = scrollContainer.scrollHeight - anchor.scrollHeight;
|
||||
scrollContainer.scrollTop = anchor.scrollTop + contentGrowth;
|
||||
historyStartPaginationStateRef.current = rearmed;
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
const applyHistoryStartPrependAnchor = useStableEvent(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const contentNode = contentRef.current;
|
||||
const anchor = historyStartPrependAnchorRef.current;
|
||||
if (
|
||||
!scrollContainer ||
|
||||
!contentNode ||
|
||||
!anchor ||
|
||||
!historyStartPrependAnchorActiveRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const anchorElement = findHistoryRowElement(contentNode, anchor.rowId);
|
||||
if (!anchorElement) {
|
||||
return;
|
||||
}
|
||||
const viewportOffset =
|
||||
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top;
|
||||
scrollContainer.scrollTop += viewportOffset - anchor.viewportOffset;
|
||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
|
||||
});
|
||||
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
|
||||
@@ -592,6 +627,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (event.deltaY < 0) {
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
};
|
||||
const handlePointerDown = () => {
|
||||
@@ -616,6 +652,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
lastTouchClientYRef.current = touch.clientY;
|
||||
};
|
||||
@@ -644,7 +681,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
||||
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
}, [cancelPendingStickToBottom, handleDomScroll]);
|
||||
}, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -711,9 +748,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
const mountedHistoryRows = useMemo(() => {
|
||||
return segments.historyMounted.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
<div key={item.id} data-history-row-id={item.id}>
|
||||
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
||||
</Fragment>
|
||||
</div>
|
||||
));
|
||||
}, [renderHistoryMountedRow, segments.historyMounted]);
|
||||
const liveHeadRows = useMemo(() => {
|
||||
@@ -767,6 +804,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
data-history-row-id={item.id}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
|
||||
@@ -179,6 +179,7 @@ function createDataImageAcquisition(input: {
|
||||
id: createPreviewAttachmentId({
|
||||
mimeType: dataImage.mimeType,
|
||||
contentLength: dataImage.base64.length,
|
||||
contentKey: dataImage.cacheKey,
|
||||
}),
|
||||
dataUrl: source,
|
||||
mimeType: dataImage.mimeType,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createImageSourceCacheKey,
|
||||
createPreviewAttachmentId,
|
||||
fileUriToPath,
|
||||
localFileSourceToPath,
|
||||
parseDataUrl,
|
||||
@@ -86,4 +87,29 @@ describe("parseImageDataUrl", () => {
|
||||
it("ignores SVG data URLs", () => {
|
||||
expect(parseImageDataUrl("data:image/svg+xml;base64,PHN2ZyAvPg==")).toBeNull();
|
||||
});
|
||||
|
||||
it("distinguishes image data that differs only in the middle", () => {
|
||||
const prefix = "a".repeat(64);
|
||||
const suffix = "z".repeat(64);
|
||||
const first = `data:image/png;base64,${prefix}${"b".repeat(256)}${suffix}`;
|
||||
const second = `data:image/png;base64,${prefix}${"c".repeat(256)}${suffix}`;
|
||||
|
||||
expect(createImageSourceCacheKey(first)).not.toBe(createImageSourceCacheKey(second));
|
||||
});
|
||||
|
||||
it("gives equal-length preview content distinct attachment identities", () => {
|
||||
expect(
|
||||
createPreviewAttachmentId({
|
||||
mimeType: "image/png",
|
||||
contentLength: 512,
|
||||
contentKey: "first-content",
|
||||
}),
|
||||
).not.toBe(
|
||||
createPreviewAttachmentId({
|
||||
mimeType: "image/png",
|
||||
contentLength: 512,
|
||||
contentKey: "second-content",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ export function parseImageDataUrl(
|
||||
if (!isRasterImageMimeType(parsed.mimeType)) {
|
||||
return null;
|
||||
}
|
||||
const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`;
|
||||
const fingerprint = `${parsed.mimeType}\0${parsed.base64}`;
|
||||
return {
|
||||
...parsed,
|
||||
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`,
|
||||
@@ -87,12 +87,15 @@ export function createPreviewAttachmentId(input: {
|
||||
size?: number | null;
|
||||
modifiedAt?: string | null;
|
||||
contentLength?: number | null;
|
||||
contentKey?: string | null;
|
||||
}): string {
|
||||
const path = input.path?.trim() ?? "";
|
||||
const size = Number.isFinite(input.size) ? String(input.size) : "";
|
||||
const modifiedAt = input.modifiedAt?.trim() ?? "";
|
||||
const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : "";
|
||||
const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`);
|
||||
const contentKey = input.contentKey?.trim() ?? "";
|
||||
const identity = `${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`;
|
||||
const hash = hashString(contentKey ? `${identity}\0${contentKey}` : identity);
|
||||
return `preview_${size || contentLength || "unknown"}_${hash}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
|
||||
Reference in New Issue
Block a user