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:
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user