Skip to content

Commit 8aae78d

Browse files
fix(web): prevent streamed search result navigation cancellation (#1577)
* fix(web): prevent streamed search navigation cancellation * chore: update changelog for #1577
1 parent 9ae8901 commit 8aae78d

3 files changed

Lines changed: 221 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- Fixed code search result links occasionally getting stuck during navigation and restored Cmd/Ctrl-click to open matches in preview. [#1574](https://github.com/sourcebot-dev/sourcebot/pull/1574)
2121
- Fixed a server-side memory leak where a single shared react-query cache retained state from every server render; the cache is now created per-request. [#1575](https://github.com/sourcebot-dev/sourcebot/pull/1575)
2222
- Fixed code host retry warnings to include the HTTP response status. [#1576](https://github.com/sourcebot-dev/sourcebot/pull/1576)
23+
- Fixed streamed code search updates silently cancelling in-flight result navigation. [#1577](https://github.com/sourcebot-dev/sourcebot/pull/1577)
2324

2425
## [5.1.6] - 2026-08-10
2526

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import type { SearchResultFile } from '@/features/search';
4+
import { SearchResultsPanel } from '.';
5+
6+
const mocks = vi.hoisted(() => ({
7+
onResultClick: () => {},
8+
virtualizerOptions: [] as Array<{
9+
count: number;
10+
initialOffset?: number;
11+
initialMeasurementsCache?: unknown[];
12+
}>,
13+
fileMatchContainerProps: [] as Array<{
14+
file: SearchResultFile;
15+
showAllMatches: boolean;
16+
}>,
17+
}));
18+
19+
vi.mock('@uidotdev/usehooks', () => ({
20+
useDebounce: <T,>(value: T) => value,
21+
}));
22+
23+
vi.mock('@tanstack/react-virtual', () => ({
24+
useVirtualizer: (options: {
25+
count: number;
26+
initialOffset?: number;
27+
initialMeasurementsCache?: unknown[];
28+
}) => {
29+
mocks.virtualizerOptions.push(options);
30+
const measurementsCache = Array.from({ length: options.count }, (_, index) => ({
31+
key: index,
32+
index,
33+
start: index * 100,
34+
end: (index + 1) * 100,
35+
size: 100,
36+
lane: 0,
37+
}));
38+
39+
return {
40+
scrollOffset: options.initialOffset ?? 0,
41+
measurementsCache,
42+
scrollToIndex: () => {},
43+
measureElement: () => {},
44+
getTotalSize: () => measurementsCache.length * 100,
45+
getVirtualItems: () => measurementsCache.slice(0, 1),
46+
};
47+
},
48+
}));
49+
50+
vi.mock('./fileMatchContainer', () => ({
51+
MAX_MATCHES_TO_PREVIEW: 3,
52+
FileMatchContainer: (props: { file: SearchResultFile; showAllMatches: boolean }) => {
53+
mocks.fileMatchContainerProps.push(props);
54+
return (
55+
<a
56+
href={`/browse/${props.file.fileName.text}`}
57+
onClick={(event) => {
58+
event.preventDefault();
59+
mocks.onResultClick();
60+
}}
61+
>
62+
{props.file.fileName.text}
63+
</a>
64+
);
65+
},
66+
}));
67+
68+
const nativeReplaceState = window.history.replaceState.bind(window.history);
69+
70+
const createFile = (fileName: string, repository = 'repo-one'): SearchResultFile => ({
71+
fileName: {
72+
text: fileName,
73+
matchRanges: [],
74+
},
75+
webUrl: '',
76+
repository,
77+
repositoryId: 1,
78+
language: 'TypeScript',
79+
branches: ['main'],
80+
chunks: [],
81+
});
82+
83+
afterEach(() => {
84+
cleanup();
85+
window.history.replaceState = nativeReplaceState;
86+
nativeReplaceState({}, '');
87+
mocks.onResultClick = () => {};
88+
mocks.virtualizerOptions.length = 0;
89+
mocks.fileMatchContainerProps.length = 0;
90+
});
91+
92+
describe('SearchResultsPanel history state', () => {
93+
it('does not cancel result navigation when streamed measurements update', async () => {
94+
const nextHistoryState = {
95+
tree: ['search'],
96+
renderedSearch: '?query=useState',
97+
};
98+
const restoredMeasurements = [{
99+
key: 0,
100+
index: 0,
101+
start: 0,
102+
end: 100,
103+
size: 100,
104+
lane: 0,
105+
}];
106+
nativeReplaceState({
107+
__NA: true,
108+
__PRIVATE_NEXTJS_INTERNALS_TREE: nextHistoryState,
109+
scrollOffset: 240,
110+
measurementsCache: restoredMeasurements,
111+
showAllMatchesMap: [['repo-one-src/index.ts', true]],
112+
}, '', '/search?query=useState');
113+
114+
const browseUrl = '/browse/repo-one/src/index.ts';
115+
let isNavigationPending = false;
116+
let navigationCancelled = false;
117+
let resolveNavigation: () => void = () => {};
118+
const navigationReady = new Promise<void>((resolve) => {
119+
resolveNavigation = resolve;
120+
});
121+
const completeNavigation = async () => {
122+
isNavigationPending = true;
123+
await navigationReady;
124+
if (!navigationCancelled) {
125+
nativeReplaceState(window.history.state, '', browseUrl);
126+
}
127+
isNavigationPending = false;
128+
};
129+
let navigationResult: Promise<void> | undefined;
130+
mocks.onResultClick = () => {
131+
navigationResult = completeNavigation();
132+
};
133+
134+
const replaceStateCalls: Array<{
135+
data: unknown;
136+
argumentCount: number;
137+
}> = [];
138+
// Mirror Next.js 16's external replaceState patch: copy its internal
139+
// state and restore the supplied URL, preempting a pending navigation.
140+
window.history.replaceState = function replaceState(data, unused, url) {
141+
replaceStateCalls.push({
142+
data,
143+
argumentCount: arguments.length,
144+
});
145+
146+
const customState = data as Record<string, unknown> | null;
147+
if (customState?.__NA || customState?._N) {
148+
nativeReplaceState(data, unused, url);
149+
return;
150+
}
151+
152+
const currentState = window.history.state as Record<string, unknown> | null;
153+
const stateWithNextInternals = {
154+
...(customState ?? {}),
155+
...(currentState?.__NA ? { __NA: currentState.__NA } : {}),
156+
...(currentState?.__PRIVATE_NEXTJS_INTERNALS_TREE ? {
157+
__PRIVATE_NEXTJS_INTERNALS_TREE: currentState.__PRIVATE_NEXTJS_INTERNALS_TREE,
158+
} : {}),
159+
};
160+
161+
if (url) {
162+
navigationCancelled = isNavigationPending;
163+
}
164+
165+
nativeReplaceState(stateWithNextInternals, unused, url);
166+
};
167+
168+
const firstFile = createFile('src/index.ts');
169+
const secondFile = createFile('src/streamed.ts');
170+
const commonProps = {
171+
onOpenFilePreview: vi.fn(),
172+
isLoadMoreButtonVisible: false,
173+
onLoadMoreButtonClicked: vi.fn(),
174+
isBranchFilteringEnabled: false,
175+
repoInfo: {},
176+
};
177+
const { rerender } = render(
178+
<SearchResultsPanel fileMatches={[firstFile]} {...commonProps} />,
179+
);
180+
181+
expect(mocks.virtualizerOptions[0]).toEqual(expect.objectContaining({
182+
initialOffset: 240,
183+
initialMeasurementsCache: restoredMeasurements,
184+
}));
185+
expect(mocks.fileMatchContainerProps[0].showAllMatches).toBe(true);
186+
187+
fireEvent.click(screen.getByRole('link', { name: 'src/index.ts' }));
188+
expect(isNavigationPending).toBe(true);
189+
190+
rerender(
191+
<SearchResultsPanel fileMatches={[firstFile, secondFile]} {...commonProps} />,
192+
);
193+
194+
expect(isNavigationPending).toBe(true);
195+
expect(navigationCancelled).toBe(false);
196+
197+
const latestReplaceStateCall = replaceStateCalls.at(-1);
198+
expect(latestReplaceStateCall?.argumentCount).toBe(2);
199+
expect(latestReplaceStateCall?.data).toEqual(expect.objectContaining({
200+
__NA: true,
201+
__PRIVATE_NEXTJS_INTERNALS_TREE: nextHistoryState,
202+
scrollOffset: 240,
203+
measurementsCache: expect.arrayContaining([
204+
expect.objectContaining({ index: 0 }),
205+
expect.objectContaining({ index: 1 }),
206+
]),
207+
}));
208+
209+
await act(async () => {
210+
resolveNavigation();
211+
await navigationResult;
212+
});
213+
214+
expect(window.location.pathname).toBe(browseUrl);
215+
});
216+
});

packages/web/src/app/(app)/search/components/searchResultsPanel/index.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,16 @@ export const SearchResultsPanel = forwardRef<SearchResultsPanelHandle, SearchRes
9999
// Save the scroll state to the history stack.
100100
const debouncedScrollOffset = useDebounce(virtualizer.scrollOffset, 500);
101101
useEffect(() => {
102+
// Keep Next.js's internal state and omit the URL so its patched
103+
// replaceState does not dispatch a route restore during navigation.
102104
history.replaceState(
103105
{
106+
...history.state,
104107
scrollOffset: debouncedScrollOffset ?? undefined,
105108
measurementsCache: virtualizer.measurementsCache,
106109
showAllMatchesMap: Array.from(showAllMatchesMap.entries()),
107110
} satisfies ScrollHistoryState,
108-
'',
109-
window.location.href
111+
''
110112
);
111113
}, [debouncedScrollOffset, virtualizer.measurementsCache, showAllMatchesMap]);
112114

0 commit comments

Comments
 (0)