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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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)
- 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)
- Fixed code host retry warnings to include the HTTP response status. [#1576](https://github.com/sourcebot-dev/sourcebot/pull/1576)
- Fixed streamed code search updates silently cancelling in-flight result navigation. [#1577](https://github.com/sourcebot-dev/sourcebot/pull/1577)

## [5.1.6] - 2026-08-10

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { SearchResultFile } from '@/features/search';
import { SearchResultsPanel } from '.';

const mocks = vi.hoisted(() => ({
onResultClick: () => {},
virtualizerOptions: [] as Array<{
count: number;
initialOffset?: number;
initialMeasurementsCache?: unknown[];
}>,
fileMatchContainerProps: [] as Array<{
file: SearchResultFile;
showAllMatches: boolean;
}>,
}));

vi.mock('@uidotdev/usehooks', () => ({
useDebounce: <T,>(value: T) => value,
}));

vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: (options: {
count: number;
initialOffset?: number;
initialMeasurementsCache?: unknown[];
}) => {
mocks.virtualizerOptions.push(options);
const measurementsCache = Array.from({ length: options.count }, (_, index) => ({
key: index,
index,
start: index * 100,
end: (index + 1) * 100,
size: 100,
lane: 0,
}));

return {
scrollOffset: options.initialOffset ?? 0,
measurementsCache,
scrollToIndex: () => {},
measureElement: () => {},
getTotalSize: () => measurementsCache.length * 100,
getVirtualItems: () => measurementsCache.slice(0, 1),
};
},
}));

vi.mock('./fileMatchContainer', () => ({
MAX_MATCHES_TO_PREVIEW: 3,
FileMatchContainer: (props: { file: SearchResultFile; showAllMatches: boolean }) => {
mocks.fileMatchContainerProps.push(props);
return (
<a
href={`/browse/${props.file.fileName.text}`}
onClick={(event) => {
event.preventDefault();
mocks.onResultClick();
}}
>
{props.file.fileName.text}
</a>
);
},
}));

const nativeReplaceState = window.history.replaceState.bind(window.history);

const createFile = (fileName: string, repository = 'repo-one'): SearchResultFile => ({
fileName: {
text: fileName,
matchRanges: [],
},
webUrl: '',
repository,
repositoryId: 1,
language: 'TypeScript',
branches: ['main'],
chunks: [],
});

afterEach(() => {
cleanup();
window.history.replaceState = nativeReplaceState;
nativeReplaceState({}, '');
mocks.onResultClick = () => {};
mocks.virtualizerOptions.length = 0;
mocks.fileMatchContainerProps.length = 0;
});

describe('SearchResultsPanel history state', () => {
it('does not cancel result navigation when streamed measurements update', async () => {
const nextHistoryState = {
tree: ['search'],
renderedSearch: '?query=useState',
};
const restoredMeasurements = [{
key: 0,
index: 0,
start: 0,
end: 100,
size: 100,
lane: 0,
}];
nativeReplaceState({
__NA: true,
__PRIVATE_NEXTJS_INTERNALS_TREE: nextHistoryState,
scrollOffset: 240,
measurementsCache: restoredMeasurements,
showAllMatchesMap: [['repo-one-src/index.ts', true]],
}, '', '/search?query=useState');

const browseUrl = '/browse/repo-one/src/index.ts';
let isNavigationPending = false;
let navigationCancelled = false;
let resolveNavigation: () => void = () => {};
const navigationReady = new Promise<void>((resolve) => {
resolveNavigation = resolve;
});
const completeNavigation = async () => {
isNavigationPending = true;
await navigationReady;
if (!navigationCancelled) {
nativeReplaceState(window.history.state, '', browseUrl);
}
isNavigationPending = false;
};
let navigationResult: Promise<void> | undefined;
mocks.onResultClick = () => {
navigationResult = completeNavigation();
};

const replaceStateCalls: Array<{
data: unknown;
argumentCount: number;
}> = [];
// Mirror Next.js 16's external replaceState patch: copy its internal
// state and restore the supplied URL, preempting a pending navigation.
window.history.replaceState = function replaceState(data, unused, url) {
replaceStateCalls.push({
data,
argumentCount: arguments.length,
});

const customState = data as Record<string, unknown> | null;
if (customState?.__NA || customState?._N) {
nativeReplaceState(data, unused, url);
return;
}

const currentState = window.history.state as Record<string, unknown> | null;
const stateWithNextInternals = {
...(customState ?? {}),
...(currentState?.__NA ? { __NA: currentState.__NA } : {}),
...(currentState?.__PRIVATE_NEXTJS_INTERNALS_TREE ? {
__PRIVATE_NEXTJS_INTERNALS_TREE: currentState.__PRIVATE_NEXTJS_INTERNALS_TREE,
} : {}),
};

if (url) {
navigationCancelled = isNavigationPending;
}

nativeReplaceState(stateWithNextInternals, unused, url);
};

const firstFile = createFile('src/index.ts');
const secondFile = createFile('src/streamed.ts');
const commonProps = {
onOpenFilePreview: vi.fn(),
isLoadMoreButtonVisible: false,
onLoadMoreButtonClicked: vi.fn(),
isBranchFilteringEnabled: false,
repoInfo: {},
};
const { rerender } = render(
<SearchResultsPanel fileMatches={[firstFile]} {...commonProps} />,
);

expect(mocks.virtualizerOptions[0]).toEqual(expect.objectContaining({
initialOffset: 240,
initialMeasurementsCache: restoredMeasurements,
}));
expect(mocks.fileMatchContainerProps[0].showAllMatches).toBe(true);

fireEvent.click(screen.getByRole('link', { name: 'src/index.ts' }));
expect(isNavigationPending).toBe(true);

rerender(
<SearchResultsPanel fileMatches={[firstFile, secondFile]} {...commonProps} />,
);

expect(isNavigationPending).toBe(true);
expect(navigationCancelled).toBe(false);

const latestReplaceStateCall = replaceStateCalls.at(-1);
expect(latestReplaceStateCall?.argumentCount).toBe(2);
expect(latestReplaceStateCall?.data).toEqual(expect.objectContaining({
__NA: true,
__PRIVATE_NEXTJS_INTERNALS_TREE: nextHistoryState,
scrollOffset: 240,
measurementsCache: expect.arrayContaining([
expect.objectContaining({ index: 0 }),
expect.objectContaining({ index: 1 }),
]),
}));

await act(async () => {
resolveNavigation();
await navigationResult;
});

expect(window.location.pathname).toBe(browseUrl);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,16 @@ export const SearchResultsPanel = forwardRef<SearchResultsPanelHandle, SearchRes
// Save the scroll state to the history stack.
const debouncedScrollOffset = useDebounce(virtualizer.scrollOffset, 500);
useEffect(() => {
// Keep Next.js's internal state and omit the URL so its patched
// replaceState does not dispatch a route restore during navigation.
history.replaceState(
{
...history.state,
scrollOffset: debouncedScrollOffset ?? undefined,
measurementsCache: virtualizer.measurementsCache,
showAllMatchesMap: Array.from(showAllMatchesMap.entries()),
} satisfies ScrollHistoryState,
'',
window.location.href
''
);
}, [debouncedScrollOffset, virtualizer.measurementsCache, showAllMatchesMap]);

Expand Down
Loading