Break browser history ties with URL for stable snapshot ordering

When browser history entries tie on tier, score, and recency, the sort
order can become non-deterministic, especially when combining browser and
document history that may be reordered in snapshots. Add normalizedUrl
as the final tie-breaker to guarantee consistent ordering.
This commit is contained in:
Jinjing
2026-08-31 23:52:58 -07:00
parent 01237b1328
commit 0e39fa4f0c
3 changed files with 36 additions and 21 deletions
@@ -76,28 +76,30 @@ export function buildBrowserAddressBarSuggestions({
.sort((a, b) => b.lastVisitedAt - a.lastVisitedAt)
.slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS)
}
const documentSuggestions = workspaceDocHistory.map(toWorkspaceDocSuggestion)
const historyRows: { entry: BrowserHistoryEntry; row: BrowserAddressBarSuggestion }[] = [
...browserUrlHistory.map((entry) => ({
entry,
row: { ...entry, subtitle: entry.url, isSearch: false }
})),
...documentSuggestions.map((row) => ({
entry: {
url: row.url,
normalizedUrl: row.url,
title: row.title,
lastVisitedAt: row.lastVisitedAt,
visitCount: row.visitCount
},
row
}))
]
const rowByEntry = new Map(historyRows.map(({ entry, row }) => [entry, row]))
const documentRows = workspaceDocHistory.map(toWorkspaceDocSuggestion)
const documentEntries: BrowserHistoryEntry[] = documentRows.map((row) => ({
url: row.url,
normalizedUrl: row.url,
title: row.title,
lastVisitedAt: row.lastVisitedAt,
visitCount: row.visitCount
}))
const rowByEntry = new Map<BrowserHistoryEntry, BrowserAddressBarSuggestion>()
for (const entry of browserUrlHistory) {
rowByEntry.set(entry, { ...entry, subtitle: entry.url, isSearch: false })
}
documentEntries.forEach((entry, index) => rowByEntry.set(entry, documentRows[index]))
// Why prepare the caller's array as-is: it is the stable `browserUrlHistory`
// identity, so the prepare cache hits instead of re-lowercasing every keystroke.
const preparedHistory = prepareBrowserHistoryEntries(browserUrlHistory)
const prepared =
documentEntries.length === 0
? preparedHistory
: [...preparedHistory, ...prepareBrowserHistoryEntries(documentEntries)]
// Why url-tail is kept here: the address bar is a navigation surface, so a
// path-only recall is still a destination — it just never outranks a real one.
const historySuggestions: BrowserAddressBarSuggestion[] = matchBrowserHistory({
prepared: prepareBrowserHistoryEntries(historyRows.map(({ entry }) => entry)),
prepared,
query: trimmed,
limit: MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS - 1
})
@@ -124,6 +124,17 @@ describe('browser history match', () => {
expect(match(tied.toReversed(), 'acme')).toEqual(first)
})
it('breaks a full tie on the url so a reordered snapshot keeps the same order', () => {
const tied = [
entry({ url: 'https://acme.dev/b', visitCount: 3, lastVisitedAt: NOW - 5 * HOUR }),
entry({ url: 'https://acme.dev/a', visitCount: 3, lastVisitedAt: NOW - 5 * HOUR })
]
const expected = ['https://acme.dev/a', 'https://acme.dev/b']
expect(match(tied, 'acme').map((row) => row.url)).toEqual(expected)
expect(match(tied.toReversed(), 'acme').map((row) => row.url)).toEqual(expected)
})
it('honours the limit and returns the shared empty array when nothing matches', () => {
expect(match(GIT_CORPUS, 'git', { limit: 2 })).toHaveLength(2)
const prepared = prepareBrowserHistoryEntries(GIT_CORPUS)
@@ -130,12 +130,14 @@ export function matchBrowserHistory({
if (matches.length === 0) {
return NO_MATCHES
}
// Why lastVisitedAt last: ordering must not wobble between renders on a tie.
// Why the url last: ordering must not wobble between renders when a snapshot
// reorders two entries that also tie on tier, score and recency.
matches.sort(
(a, b) =>
TIER_RANK[a.tier] - TIER_RANK[b.tier] ||
b.score - a.score ||
b.entry.lastVisitedAt - a.entry.lastVisitedAt
b.entry.lastVisitedAt - a.entry.lastVisitedAt ||
a.entry.normalizedUrl.localeCompare(b.entry.normalizedUrl)
)
return matches.slice(0, limit)
}