diff --git a/CHANGELOG.md b/CHANGELOG.md index 2539579e5..0624716dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/web/src/app/(app)/search/components/searchResultsPanel/index.test.tsx b/packages/web/src/app/(app)/search/components/searchResultsPanel/index.test.tsx new file mode 100644 index 000000000..3b0a9a195 --- /dev/null +++ b/packages/web/src/app/(app)/search/components/searchResultsPanel/index.test.tsx @@ -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: (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 ( + { + event.preventDefault(); + mocks.onResultClick(); + }} + > + {props.file.fileName.text} + + ); + }, +})); + +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((resolve) => { + resolveNavigation = resolve; + }); + const completeNavigation = async () => { + isNavigationPending = true; + await navigationReady; + if (!navigationCancelled) { + nativeReplaceState(window.history.state, '', browseUrl); + } + isNavigationPending = false; + }; + let navigationResult: Promise | 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 | null; + if (customState?.__NA || customState?._N) { + nativeReplaceState(data, unused, url); + return; + } + + const currentState = window.history.state as Record | 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( + , + ); + + 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( + , + ); + + 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); + }); +}); diff --git a/packages/web/src/app/(app)/search/components/searchResultsPanel/index.tsx b/packages/web/src/app/(app)/search/components/searchResultsPanel/index.tsx index b2ee8e9e7..d2174ef0e 100644 --- a/packages/web/src/app/(app)/search/components/searchResultsPanel/index.tsx +++ b/packages/web/src/app/(app)/search/components/searchResultsPanel/index.tsx @@ -99,14 +99,16 @@ export const SearchResultsPanel = forwardRef { + // 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]);