fix(desktop): keep offset-drifted backfill rows in order (#119606)

Co-authored-by: Xipong <217837358+Xipong@users.noreply.github.com>
This commit is contained in:
Xipong
2026-09-23 17:12:13 +03:00
committed by GitHub
parent 746f7ea21b
commit 5c6b56a866
2 changed files with 118 additions and 15 deletions

View File

@@ -122,6 +122,19 @@ describe('mergeOlderTranscriptPage', () => {
expect(mergeOlderTranscriptPage(existing, older).map(m => m.rowId)).toEqual([1, 2, 3])
})
it('keeps newer rows after an old tail when a drifting offset returns both overlap and subsequent turns', () => {
// A page initially ending at row 6 was cached. New turns persisted before
// the older-page request, so its offset now lands across rows 4–8.
const existing = [chat('one', 1), chat('two', 2), chat('three', 3), chat('four', 4), chat('five', 5), chat('six', 6)]
const fetched = [chat('four-refetched', 4), chat('five-refetched', 5), chat('six-refetched', 6), chat('seven', 7), chat('eight', 8)]
const merged = mergeOlderTranscriptPage(existing, fetched)
expect(merged.map(message => message.rowId)).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
expect(merged.slice(0, 6)).toEqual(existing)
expect(mergeOlderTranscriptPage(merged, fetched)).toBe(merged)
})
it('keeps reference identity when every older row is already present', () => {
const existing = [chat('a', 1), chat('b', 2)]
const older = [chat('a', 1)]
@@ -201,6 +214,52 @@ describe('backfillOlderTranscriptPage', () => {
expect(transcriptBackfillAvailable('stored-1')).toBe(false)
})
it('keeps the live tail in order when the fetched offset page includes subsequently persisted rows', async () => {
recordTranscriptTail('stored-1', {
messages: [row(4, 'four'), row(5, 'five'), row(6, 'six')],
pagination: { limit: 3, offset: 0, order: 'latest', returned: 3 }
})
// Four rows persisted since hydration. Offset 3 now selects rows 5–7,
// rather than a page wholly before the cached 4–6 tail.
vi.mocked(getOlderSessionMessages).mockResolvedValue({
messages: [row(5, 'five'), row(6, 'six'), row(7, 'seven')],
pagination: { limit: 3, offset: 3, order: 'latest', returned: 3 },
session_id: 'stored-1'
} as never)
let visible = [chat('four', 4), chat('five', 5), chat('six', 6)]
const applied = await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage: page => {
visible = mergeOlderTranscriptPage(visible, page)
}
})
expect(applied).toBe(true)
expect(getOlderSessionMessages).toHaveBeenCalledWith('stored-1', undefined, 3)
expect(visible.map(message => message.rowId)).toEqual([4, 5, 6, 7])
expect(transcriptTailState('stored-1')?.nextOffset).toBe(6)
vi.mocked(getOlderSessionMessages).mockResolvedValue({
messages: [row(2, 'two'), row(3, 'three'), row(4, 'four')],
pagination: { limit: 3, offset: 6, order: 'latest', returned: 3 },
session_id: 'stored-1'
} as never)
await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage: page => {
visible = mergeOlderTranscriptPage(visible, page)
}
})
expect(getOlderSessionMessages).toHaveBeenLastCalledWith('stored-1', undefined, 6)
expect(visible.map(message => message.rowId)).toEqual([2, 3, 4, 5, 6, 7])
})
it('backfills the matching connection when two owners share one session id', async () => {
const sourceA = { connectionId: 'source-a', profile: 'backend-a' }
const sourceB = { connectionId: 'source-b', profile: 'backend-b' }

View File

@@ -5,12 +5,12 @@
* session. "Show earlier" first pages the DOM budget, then the in-memory store
* window — and when the whole in-memory transcript is materialized but the
* REST hydration was truncated (`transcript-tail` bookkeeping), this module
* fetches the next older page and prepends it to the session store.
* fetches the next older page and merges it into the session store.
*
* Offsets follow the backend's `order: 'latest'` semantics: measured back
* from the NEWEST persisted row. Rows persisted after hydration shift that
* origin, so a fetched page can overlap rows we already hold — the prepend
* dedupes by durable row id (falling back to the rendered message id) and
* origin, so a fetched page can overlap rows we already hold and even extend
* past the cached tail. Shared durable rows anchor the merge on either side;
* the offset still advances by the fetched count, which self-corrects the
* drift on the next page.
*/
@@ -28,8 +28,10 @@ export function transcriptBackfillAvailable(
}
/**
* Prepend an older page onto the in-memory transcript, deduplicating rows the
* store already holds (offset drift makes overlap normal — see module doc).
* Merge a fetched page into the in-memory transcript, deduplicating rows
* the store already holds (offset drift makes overlap normal — see module doc).
* A page with no shared row is presumed older; overlapping pages use their
* shared rows to place fresh messages before, within, or after the cached tail.
* Preserves reference identity when nothing changes: handing React a fresh
* array of the same messages re-renders the runtime for nothing.
*/
@@ -41,26 +43,68 @@ export function mergeOlderTranscriptPage(existing: ChatMessage[], olderPage: Cha
return existing
}
const existingRowIds = new Set<number>()
const existingIds = new Set<string>()
const existingRowIndices = new Map<number, number>()
const existingIdIndices = new Map<string, number>()
for (const message of existing) {
existing.forEach((message, index) => {
if (message.rowId !== undefined) {
existingRowIds.add(message.rowId)
existingRowIndices.set(message.rowId, index)
}
existingIds.add(message.id)
existingIdIndices.set(message.id, index)
})
// The offset counts backwards from the newest durable row. While a long
// turn persists, an "older" page can overlap the cached tail AND extend
// beyond its end. Position fresh rows by the shared anchors, not by the
// page's requested direction.
const insertions = new Map<number, ChatMessage[]>()
let pending: ChatMessage[] = []
let lastAnchor = -1
for (const message of olderPage) {
const anchor =
(message.rowId !== undefined ? existingRowIndices.get(message.rowId) : undefined) ??
existingIdIndices.get(message.id)
if (anchor === undefined) {
pending.push(message)
continue
}
if (pending.length) {
insertions.set(anchor, [...(insertions.get(anchor) ?? []), ...pending])
pending = []
}
lastAnchor = anchor
}
const fresh = olderPage.filter(
message => !(message.rowId !== undefined && existingRowIds.has(message.rowId)) && !existingIds.has(message.id)
)
if (pending.length) {
const position = lastAnchor < 0 ? 0 : lastAnchor + 1
insertions.set(position, [...(insertions.get(position) ?? []), ...pending])
}
if (fresh.length === 0) {
if (insertions.size === 0) {
return existing
}
return [...fresh, ...existing]
const merged: ChatMessage[] = []
for (let index = 0; index <= existing.length; index++) {
const additions = insertions.get(index)
if (additions) {
merged.push(...additions)
}
if (index < existing.length) {
merged.push(existing[index])
}
}
return merged
}
/**