fix(collab): detach reconciled loader on idle

(cherry picked from commit d675de815644a5b02358fa90d83e5cd5c78141d2)
This commit is contained in:
can1357
2026-07-29 22:37:37 +02:00
parent 872a931795
commit 650a8f0faf
2 changed files with 18 additions and 4 deletions
@@ -85,6 +85,7 @@ interface PendingSnapshot {
/** Minimal context surface the idle-state reconciler mutates. */
export interface GuestIdleReconcilerCtx {
statusLine: { markActivityEnd: () => void };
statusContainer: Pick<InteractiveModeContext["statusContainer"], "disposeChildren">;
loadingAnimation: { stop: () => void } | undefined;
}
@@ -106,6 +107,7 @@ export function reconcileGuestIdleHostState(ctx: GuestIdleReconcilerCtx, isStrea
if (ctx.loadingAnimation) {
ctx.loadingAnimation.stop();
ctx.loadingAnimation = undefined;
ctx.statusContainer.disposeChildren();
}
}
@@ -39,16 +39,20 @@ interface Fixture {
ctx: GuestIdleReconcilerCtx;
markActivityEnd: Mock<() => void>;
loaderStop: Mock<() => void>;
visibleChildren: object[];
}
function makeCtx(hasLoader: boolean): Fixture {
const markActivityEnd: Mock<() => void> = mock(() => {});
const loaderStop: Mock<() => void> = mock(() => {});
const loader = { stop: loaderStop };
const visibleChildren: object[] = hasLoader ? [loader] : [];
const ctx: GuestIdleReconcilerCtx = {
statusLine: { markActivityEnd },
loadingAnimation: hasLoader ? { stop: loaderStop } : undefined,
statusContainer: { disposeChildren: () => visibleChildren.splice(0) },
loadingAnimation: hasLoader ? loader : undefined,
};
return { ctx, markActivityEnd, loaderStop };
return { ctx, markActivityEnd, loaderStop, visibleChildren };
}
function makeSession(): ConstructorParameters<typeof StatusLineComponent>[0] {
@@ -87,12 +91,13 @@ function makeSession(): ConstructorParameters<typeof StatusLineComponent>[0] {
describe("reconcileGuestIdleHostState", () => {
it("closes the active-time window and stops the loader when the host reports idle", () => {
const { ctx, markActivityEnd, loaderStop } = makeCtx(true);
const { ctx, markActivityEnd, loaderStop, visibleChildren } = makeCtx(true);
reconcileGuestIdleHostState(ctx, false);
expect(markActivityEnd).toHaveBeenCalledTimes(1);
expect(loaderStop).toHaveBeenCalledTimes(1);
// Loader is cleared so a second reconciliation does not re-stop it.
expect(ctx.loadingAnimation).toBeUndefined();
expect(visibleChildren).toEqual([]);
});
it("is a no-op while the host is still streaming so live turns keep the meter open", () => {
@@ -135,6 +140,7 @@ describe("reconcileGuestSnapshotHostState", () => {
const ensureLoadingAnimation = mock(() => {});
const ctx: GuestSnapshotActivityReconcilerCtx = {
statusLine,
statusContainer: { disposeChildren: () => {} },
loadingAnimation: undefined,
ensureLoadingAnimation,
autoCompactionLoader: undefined,
@@ -157,6 +163,7 @@ describe("reconcileGuestSnapshotHostState", () => {
const ensureLoadingAnimation = mock(() => {});
const ctx: GuestSnapshotActivityReconcilerCtx = {
statusLine,
statusContainer: { disposeChildren: () => {} },
loadingAnimation: undefined,
ensureLoadingAnimation,
autoCompactionLoader: undefined,
@@ -179,7 +186,10 @@ describe("reconcileGuestSnapshotHostState", () => {
});
const ctx: GuestSnapshotActivityReconcilerCtx & { statusContainer: { clear: () => void } } = {
statusLine: new StatusLineComponent(makeSession()),
statusContainer: { clear: () => visibleChildren.splice(0) },
statusContainer: {
clear: () => visibleChildren.splice(0),
disposeChildren: () => visibleChildren.splice(0),
},
loadingAnimation: undefined,
ensureLoadingAnimation,
autoCompactionLoader:
@@ -201,6 +211,7 @@ describe("reconcileGuestSnapshotHostState", () => {
const ensureLoadingAnimation = mock(() => {});
const ctx: GuestSnapshotActivityReconcilerCtx = {
statusLine: new StatusLineComponent(makeSession()),
statusContainer: { disposeChildren: () => {} },
loadingAnimation: undefined,
ensureLoadingAnimation,
autoCompactionLoader: undefined,
@@ -214,6 +225,7 @@ describe("reconcileGuestSnapshotHostState", () => {
const ensureLoadingAnimation = mock(() => {});
const ctx: GuestSnapshotActivityReconcilerCtx = {
statusLine: new StatusLineComponent(makeSession()),
statusContainer: { disposeChildren: () => {} },
loadingAnimation: undefined,
ensureLoadingAnimation,
autoCompactionLoader: {} as GuestSnapshotActivityReconcilerCtx["autoCompactionLoader"],