fix(app): isolate live session updates from scrolling
Defer and coalesce global catalogue refreshes while SessionDetail is active, carry session metadata through incremental patches, and gate in-flight catalogue commits by route. Harden the virtual timeline cold-open and user-scroll lifecycle, and add Electron frame, continuity, metadata, and overlap regressions.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createGlobalDataRefreshCoordinator } from '../app/src/renderer/src/session-global-refresh.mjs';
|
||||
|
||||
test('conversation detail defers and coalesces global catalogue invalidations until route exit', async () => {
|
||||
let conversationDetailActive = true;
|
||||
let loads = 0;
|
||||
const coordinator = createGlobalDataRefreshCoordinator({
|
||||
isDeferred: () => conversationDetailActive,
|
||||
load: async () => ++loads,
|
||||
commit: () => {},
|
||||
});
|
||||
|
||||
await coordinator.invalidate();
|
||||
await coordinator.invalidate();
|
||||
await coordinator.invalidate();
|
||||
assert.equal(loads, 0, 'detail updates never start a global catalogue IPC');
|
||||
|
||||
conversationDetailActive = false;
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 1, 'route exit loads the latest invalidation exactly once');
|
||||
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 1, 'an idle route flush is a no-op');
|
||||
});
|
||||
|
||||
test('initial catalogue load is allowed on a cold conversation route', async () => {
|
||||
let loads = 0;
|
||||
const coordinator = createGlobalDataRefreshCoordinator({
|
||||
isDeferred: () => true,
|
||||
load: async () => ++loads,
|
||||
commit: () => {},
|
||||
});
|
||||
|
||||
await coordinator.initialize();
|
||||
assert.equal(loads, 1, 'deep links still receive the catalogue needed to resolve the session');
|
||||
|
||||
await coordinator.invalidate();
|
||||
assert.equal(loads, 1, 'later daemon invalidations remain deferred');
|
||||
});
|
||||
|
||||
test('an invalidation arriving during a load is retained without overlapping loads', async () => {
|
||||
let deferred = false;
|
||||
let loads = 0;
|
||||
let activeLoads = 0;
|
||||
let maxActiveLoads = 0;
|
||||
let releaseFirstLoad;
|
||||
const firstLoadGate = new Promise(resolve => { releaseFirstLoad = resolve; });
|
||||
const coordinator = createGlobalDataRefreshCoordinator({
|
||||
isDeferred: () => deferred,
|
||||
load: async () => {
|
||||
loads++;
|
||||
activeLoads++;
|
||||
maxActiveLoads = Math.max(maxActiveLoads, activeLoads);
|
||||
if (loads === 1) await firstLoadGate;
|
||||
activeLoads--;
|
||||
return loads;
|
||||
},
|
||||
commit: () => {},
|
||||
});
|
||||
|
||||
const first = coordinator.invalidate();
|
||||
const second = coordinator.invalidate();
|
||||
deferred = true;
|
||||
releaseFirstLoad();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
assert.equal(loads, 1, 'detail activation prevents the queued reload');
|
||||
assert.equal(maxActiveLoads, 1, 'global snapshots never overlap');
|
||||
|
||||
deferred = false;
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 2, 'route exit catches up with the retained invalidation');
|
||||
});
|
||||
|
||||
test('a failed catalogue load remains dirty for the next flush', async () => {
|
||||
let loads = 0;
|
||||
const coordinator = createGlobalDataRefreshCoordinator({
|
||||
isDeferred: () => false,
|
||||
load: async () => {
|
||||
loads++;
|
||||
if (loads === 1) throw new Error('temporary IPC failure');
|
||||
return loads;
|
||||
},
|
||||
commit: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(coordinator.invalidate(), /temporary IPC failure/);
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 2, 'the failed invalidation is retried instead of being lost');
|
||||
});
|
||||
|
||||
test('a catalogue fetched before navigation never commits inside conversation detail', async () => {
|
||||
let deferred = false;
|
||||
let loads = 0;
|
||||
let releaseLoad;
|
||||
const loadGate = new Promise(resolve => { releaseLoad = resolve; });
|
||||
const commits = [];
|
||||
const coordinator = createGlobalDataRefreshCoordinator({
|
||||
isDeferred: () => deferred,
|
||||
load: async () => {
|
||||
loads++;
|
||||
await loadGate;
|
||||
return 'catalogue-snapshot';
|
||||
},
|
||||
commit: snapshot => { commits.push(snapshot); },
|
||||
});
|
||||
|
||||
const request = coordinator.invalidate();
|
||||
deferred = true;
|
||||
releaseLoad();
|
||||
await request;
|
||||
|
||||
assert.equal(loads, 1);
|
||||
assert.deepEqual(commits, [], 'route activation gates the reactive commit after IPC resolves');
|
||||
|
||||
deferred = false;
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 1, 'the already-fetched snapshot is reused');
|
||||
assert.deepEqual(commits, ['catalogue-snapshot']);
|
||||
});
|
||||
Reference in New Issue
Block a user