Compare commits

..

1 Commits

Author SHA1 Message Date
midzelis 5a555972d8 fix(web): use Math.max for currentRowHeight in layoutTimelineMonth()
Change-Id: I7a618a93c46ec62b301ea7b38466d4a26a6a6964
Change-Id: I1d97efc097538cdcb525d9cf228f88406a6a6964
2026-04-07 01:04:18 +00:00
5 changed files with 282 additions and 205 deletions
@@ -46,6 +46,7 @@ export function layoutTimelineMonth(timelineManager: TimelineManager, month: Tim
} else {
// Move to next row
cumulativeHeight += currentRowHeight;
currentRowHeight = 0;
cumulativeWidth = 0;
timelineDayRow++;
timelineDayCol = 0;
@@ -59,7 +60,7 @@ export function layoutTimelineMonth(timelineManager: TimelineManager, month: Tim
timelineDayCol++;
cumulativeWidth += timelineDay.width + timelineManager.gap;
}
currentRowHeight = timelineDay.height + timelineManager.headerHeight;
currentRowHeight = Math.max(currentRowHeight, timelineDay.height + timelineManager.headerHeight);
}
// Add the height of the final row
@@ -1,5 +1,6 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { layoutTimelineMonth } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { getTimelineMonthByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
@@ -145,7 +146,7 @@ describe('TimelineManager', () => {
it('cancels month loading', async () => {
const month = getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })!;
void timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(month!.loader!.abortController!, 'abort');
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel();
expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
@@ -638,8 +639,12 @@ describe('TimelineManager', () => {
const previousMonth = getTimelineMonthByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getFirstAsset();
const b = previousMonth!.getFirstAsset();
const loadTimelineMonthSpy = vi.spyOn(month!.loader!, 'execute');
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
});
it('skips removed assets', async () => {
@@ -767,6 +772,100 @@ describe('TimelineManager', () => {
});
});
describe('layoutTimelineMonth', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 1588, height: 1000 });
});
it('uses tallest day height when multiple days share a row', () => {
const day20Asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
const day10Asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-10T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([day20Asset, day10Asset]);
const month = timelineManager.months[0];
expect(month.timelineDays).toHaveLength(2);
const [tallDay, shortDay] = month.timelineDays;
vi.spyOn(tallDay, 'layout').mockImplementation(() => {
tallDay.width = 400;
tallDay.height = 300;
});
vi.spyOn(shortDay, 'layout').mockImplementation(() => {
shortDay.width = 200;
shortDay.height = 150;
});
layoutTimelineMonth(timelineManager, month);
// Both days fit in one row: 400 + 12 (gap) + 200 = 612 < 1588
expect(tallDay.row).toBe(0);
expect(shortDay.row).toBe(0);
// Month height should use the tallest day: 300 + 48 (headerHeight) = 348
expect(month.height).toBe(300 + timelineManager.headerHeight);
});
it('resets row height tracking when starting a new row', () => {
const day30Asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-30T12:00:00.000Z'),
}),
);
const day20Asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
const day10Asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-10T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([day30Asset, day20Asset, day10Asset]);
const month = timelineManager.months[0];
expect(month.timelineDays).toHaveLength(3);
const [day1, day2, day3] = month.timelineDays;
// Row 0: day1 (wide, tall) fills the row
vi.spyOn(day1, 'layout').mockImplementation(() => {
day1.width = 1500;
day1.height = 400;
});
// Row 1: day2 and day3 share a row
vi.spyOn(day2, 'layout').mockImplementation(() => {
day2.width = 300;
day2.height = 200;
});
vi.spyOn(day3, 'layout').mockImplementation(() => {
day3.width = 300;
day3.height = 100;
});
layoutTimelineMonth(timelineManager, month);
expect(day1.row).toBe(0);
expect(day2.row).toBe(1);
expect(day3.row).toBe(1);
const headerHeight = timelineManager.headerHeight;
// Row 0: 400 + 48 = 448. Row 1: max(200, 100) + 48 = 248. Total = 696
expect(month.height).toBe(400 + headerHeight + (200 + headerHeight));
});
});
describe('showAssetOwners', () => {
const LS_KEY = 'album-show-asset-owners';
@@ -307,8 +307,8 @@ export class TimelineManager extends VirtualScrollManager {
return;
}
if (!this.initTask.succeeded) {
await (this.initTask.running ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
if (!this.initTask.executed) {
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
}
const changedWidth = viewport.width !== this.viewportWidth;
@@ -351,10 +351,14 @@ export class TimelineManager extends VirtualScrollManager {
return;
}
if (timelineMonth.loader?.executed) {
return;
}
const executionStatus = await timelineMonth.loader?.execute(async (signal: AbortSignal) => {
await loadFromTimeBuckets(this, timelineMonth, this.#options, signal);
}, cancelable);
if (executionStatus === 'SUCCESS') {
if (executionStatus === 'LOADED') {
updateGeometry(this, timelineMonth, { invalidateHeight: false });
this.updateViewportProximities();
}
@@ -368,7 +372,7 @@ export class TimelineManager extends VirtualScrollManager {
async findTimelineMonthForAsset(asset: AssetDescriptor | AssetResponseDto) {
if (!this.isInitialized) {
await this.initTask.waitUntilSucceeded();
await this.initTask.waitUntilExecution();
}
const { id } = asset;
+124 -138
View File
@@ -2,39 +2,39 @@ import { CancellableTask } from '$lib/utils/cancellable-task';
describe('CancellableTask', () => {
describe('execute', () => {
it('should execute task successfully and return SUCCESS', async () => {
it('should execute task successfully and return LOADED', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async (_: AbortSignal) => {
const taskFn = vi.fn(async (_: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
const result = await task.execute(taskFunction, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
expect(task.running).toBe(false);
expect(taskFunction).toHaveBeenCalledTimes(1);
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
expect(task.loading).toBe(false);
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should call succeededCallback when task completes successfully', async () => {
const succeededCallback = vi.fn();
const task = new CancellableTask(succeededCallback);
const taskFunction = vi.fn(async () => {});
it('should call loadedCallback when task completes successfully', async () => {
const loadedCallback = vi.fn();
const task = new CancellableTask(loadedCallback);
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
expect(succeededCallback).toHaveBeenCalledTimes(1);
expect(loadedCallback).toHaveBeenCalledTimes(1);
});
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
const result = await task.execute(taskFunction, true);
await task.execute(taskFn, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('DONE');
expect(taskFunction).toHaveBeenCalledTimes(1);
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should wait if task is already running', async () => {
@@ -43,42 +43,42 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = vi.fn(async () => {
const taskFn = vi.fn(async () => {
await taskPromise;
});
const promise1 = task.execute(taskFunction, true);
const promise2 = task.execute(taskFunction, true);
const promise1 = task.execute(taskFn, true);
const promise2 = task.execute(taskFn, true);
expect(task.running).toBe(true);
expect(task.loading).toBe(true);
resolveTask!();
const [result1, result2] = await Promise.all([promise1, promise2]);
expect(result1).toBe('SUCCESS');
expect(result1).toBe('LOADED');
expect(result2).toBe('WAITED');
expect(taskFunction).toHaveBeenCalledTimes(1);
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should pass AbortSignal to task function', async () => {
const task = new CancellableTask();
let capturedSignal: AbortSignal | null = null;
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
await Promise.resolve();
capturedSignal = signal;
};
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
expect(capturedSignal).toBeInstanceOf(AbortSignal);
});
it('should set cancellable flag correctly', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
expect(task.cancellable).toBe(true);
const promise = task.execute(taskFunction, false);
const promise = task.execute(taskFn, false);
expect(task.cancellable).toBe(false);
await promise;
});
@@ -89,14 +89,14 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = vi.fn(async () => {
const taskFn = vi.fn(async () => {
await taskPromise;
});
const promise1 = task.execute(taskFunction, false);
const promise1 = task.execute(taskFn, false);
expect(task.cancellable).toBe(false);
const promise2 = task.execute(taskFunction, true);
const promise2 = task.execute(taskFn, true);
expect(task.cancellable).toBe(false);
resolveTask!();
@@ -108,7 +108,7 @@ describe('CancellableTask', () => {
it('should cancel a running task', async () => {
const task = new CancellableTask();
let taskStarted = false;
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
taskStarted = true;
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
@@ -116,7 +116,9 @@ describe('CancellableTask', () => {
}
};
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
// Wait a bit to ensure task has started
await new Promise((resolve) => setTimeout(resolve, 10));
expect(taskStarted).toBe(true);
@@ -124,20 +126,20 @@ describe('CancellableTask', () => {
const result = await promise;
expect(result).toBe('CANCELED');
expect(task.succeeded).toBe(false);
expect(task.executed).toBe(false);
});
it('should call canceledCallback when task is canceled', async () => {
const canceledCallback = vi.fn();
const task = new CancellableTask(undefined, canceledCallback);
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
await promise;
@@ -147,79 +149,55 @@ describe('CancellableTask', () => {
it('should not cancel if task is not cancellable', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {
const taskFn = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
const promise = task.execute(taskFunction, false);
const promise = task.execute(taskFn, false);
task.cancel();
const result = await promise;
expect(result).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
});
it('should return CANCELED when concurrent caller is waiting and task is canceled', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = async (signal: AbortSignal) => {
await taskPromise;
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise1 = task.execute(taskFunction, true);
const promise2 = task.execute(taskFunction, true);
task.cancel();
resolveTask!();
const [result1, result2] = await Promise.all([promise1, promise2]);
expect(result1).toBe('CANCELED');
expect(result2).toBe('CANCELED');
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
});
it('should not cancel if task is already executed', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
expect(task.succeeded).toBe(true);
await task.execute(taskFn, true);
expect(task.executed).toBe(true);
task.cancel();
expect(task.succeeded).toBe(true);
expect(task.executed).toBe(true);
});
});
describe('reset', () => {
it('should reset task to initial state', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
expect(task.succeeded).toBe(true);
await task.execute(taskFn, true);
expect(task.executed).toBe(true);
await task.reset();
expect(task.succeeded).toBe(false);
expect(task.abortController).toBe(null);
expect(task.running).toBe(false);
expect(task.executed).toBe(false);
expect(task.cancelToken).toBe(null);
expect(task.loading).toBe(false);
});
it('should cancel running task before resetting', async () => {
const task = new CancellableTask();
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
const resetPromise = task.reset();
@@ -227,30 +205,30 @@ describe('CancellableTask', () => {
await promise;
await resetPromise;
expect(task.succeeded).toBe(false);
expect(task.running).toBe(false);
expect(task.executed).toBe(false);
expect(task.loading).toBe(false);
});
it('should allow re-execution after reset', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
await task.reset();
const result = await task.execute(taskFunction, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
expect(taskFunction).toHaveBeenCalledTimes(2);
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
expect(taskFn).toHaveBeenCalledTimes(2);
});
});
describe('waitUntilCompletion', () => {
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
const result = await task.waitUntilCompletion();
expect(result).toBe('DONE');
@@ -262,11 +240,11 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = async () => {
const taskFn = async () => {
await taskPromise;
};
const executePromise = task.execute(taskFunction, true);
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilCompletion();
resolveTask!();
@@ -278,14 +256,14 @@ describe('CancellableTask', () => {
it('should return CANCELED if task is canceled', async () => {
const task = new CancellableTask();
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const executePromise = task.execute(taskFunction, true);
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilCompletion();
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -297,13 +275,13 @@ describe('CancellableTask', () => {
});
});
describe('waitUntilSucceeded', () => {
describe('waitUntilExecution', () => {
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
const result = await task.waitUntilSucceeded();
await task.execute(taskFn, true);
const result = await task.waitUntilExecution();
expect(result).toBe('DONE');
});
@@ -314,12 +292,12 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = async () => {
const taskFn = async () => {
await taskPromise;
};
const executePromise = task.execute(taskFunction, true);
const waitPromise = task.waitUntilSucceeded();
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilExecution();
resolveTask!();
@@ -333,7 +311,7 @@ describe('CancellableTask', () => {
const task = new CancellableTask();
let attempt = 0;
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
attempt++;
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted && attempt === 1) {
@@ -342,8 +320,8 @@ describe('CancellableTask', () => {
};
// Start first execution
const executePromise1 = task.execute(taskFunction, true);
const waitPromise = task.waitUntilSucceeded();
const executePromise1 = task.execute(taskFn, true);
const waitPromise = task.waitUntilExecution();
// Cancel the first execution
vi.advanceTimersByTime(10);
@@ -352,12 +330,12 @@ describe('CancellableTask', () => {
await executePromise1;
// Start second execution
const executePromise2 = task.execute(taskFunction, true);
const executePromise2 = task.execute(taskFn, true);
vi.advanceTimersByTime(100);
const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]);
expect(executeResult).toBe('SUCCESS');
expect(executeResult).toBe('LOADED');
expect(waitResult).toBe('WAITED');
expect(attempt).toBe(2);
@@ -369,98 +347,98 @@ describe('CancellableTask', () => {
it('should return ERRORED when task throws non-abort error', async () => {
const task = new CancellableTask();
const error = new Error('Task failed');
const taskFunction = async () => {
const taskFn = async () => {
await Promise.resolve();
throw error;
};
const result = await task.execute(taskFunction, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('ERRORED');
expect(task.succeeded).toBe(false);
expect(task.executed).toBe(false);
});
it('should call errorCallback when task throws non-abort error', async () => {
const errorCallback = vi.fn();
const task = new CancellableTask(undefined, undefined, errorCallback);
const error = new Error('Task failed');
const taskFunction = async () => {
const taskFn = async () => {
await Promise.resolve();
throw error;
};
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
expect(errorCallback).toHaveBeenCalledTimes(1);
expect(errorCallback).toHaveBeenCalledWith(error);
});
it('should return ERRORED when task throws AbortError without signal being aborted', async () => {
it('should return CANCELED when task throws AbortError', async () => {
const task = new CancellableTask();
const taskFunction = async () => {
const taskFn = async () => {
await Promise.resolve();
throw new DOMException('Aborted', 'AbortError');
};
const result = await task.execute(taskFunction, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('ERRORED');
expect(task.succeeded).toBe(false);
expect(result).toBe('CANCELED');
expect(task.executed).toBe(false);
});
it('should allow re-execution after error', async () => {
const task = new CancellableTask();
const taskFunction1 = async () => {
const taskFn1 = async () => {
await Promise.resolve();
throw new Error('Failed');
};
const taskFunction2 = vi.fn(async () => {});
const taskFn2 = vi.fn(async () => {});
const result1 = await task.execute(taskFunction1, true);
const result1 = await task.execute(taskFn1, true);
expect(result1).toBe('ERRORED');
const result2 = await task.execute(taskFunction2, true);
expect(result2).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
const result2 = await task.execute(taskFn2, true);
expect(result2).toBe('LOADED');
expect(task.executed).toBe(true);
});
});
describe('running property', () => {
describe('loading property', () => {
it('should return true when task is running', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = async () => {
const taskFn = async () => {
await taskPromise;
};
expect(task.running).toBe(false);
expect(task.loading).toBe(false);
const promise = task.execute(taskFunction, true);
expect(task.running).toBe(true);
const promise = task.execute(taskFn, true);
expect(task.loading).toBe(true);
resolveTask!();
await promise;
expect(task.running).toBe(false);
expect(task.loading).toBe(false);
});
});
describe('complete promise', () => {
it('should resolve when task completes successfully', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
const completePromise = task.complete;
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
await expect(completePromise).resolves.toBeUndefined();
});
it('should reject when task is canceled', async () => {
const task = new CancellableTask();
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
@@ -468,7 +446,7 @@ describe('CancellableTask', () => {
};
const completePromise = task.complete;
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
await promise;
@@ -478,13 +456,13 @@ describe('CancellableTask', () => {
it('should reject when task errors', async () => {
const task = new CancellableTask();
const taskFunction = async () => {
const taskFn = async () => {
await Promise.resolve();
throw new Error('Failed');
};
const completePromise = task.complete;
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
await expect(completePromise).rejects.toBeUndefined();
});
@@ -494,22 +472,27 @@ describe('CancellableTask', () => {
it('should automatically call abort() on signal when task is canceled', async () => {
const task = new CancellableTask();
let capturedSignal: AbortSignal | null = null;
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
capturedSignal = signal;
// Simulate a long-running task
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
// Wait a bit to ensure task has started
await new Promise((resolve) => setTimeout(resolve, 10));
expect(capturedSignal).not.toBeNull();
expect(capturedSignal!.aborted).toBe(false);
// Cancel the task
task.cancel();
// Verify the signal was aborted
expect(capturedSignal!.aborted).toBe(true);
const result = await promise;
@@ -519,22 +502,25 @@ describe('CancellableTask', () => {
it('should detect if signal was aborted after task completes', async () => {
const task = new CancellableTask();
let controller: AbortController | null = null;
const taskFunction = async (_: AbortSignal) => {
// Capture the controller to abort it externally before the function returns
controller = task.abortController;
const taskFn = async (_: AbortSignal) => {
// Capture the controller to abort it externally
controller = task.cancelToken;
// Simulate some work
await new Promise((resolve) => setTimeout(resolve, 10));
// Now abort before the function returns
controller?.abort();
};
const result = await task.execute(taskFunction, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('CANCELED');
expect(task.succeeded).toBe(false);
expect(task.executed).toBe(false);
});
it('should handle abort signal in async operations', async () => {
const task = new CancellableTask();
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
// Simulate listening to abort signal during async operation
return new Promise<void>((resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
@@ -543,7 +529,7 @@ describe('CancellableTask', () => {
});
};
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
+48 -61
View File
@@ -1,60 +1,47 @@
/**
* A one-shot async task with cancellation support via AbortController/AbortSignal.
*
* State machine:
*
* IDLE ──execute()──▶ RUNNING ──task succeeds──▶ SUCCEEDED (terminal)
* │
* ├──cancel()/abort──▶ CANCELED ──▶ IDLE
* └──task throws─────▶ ERRORED ──▶ IDLE
*
* SUCCEEDED is terminal — further execute() calls return 'DONE'.
* Call reset() to move from SUCCEEDED back to IDLE for re-execution.
*
* execute() return values: 'SUCCESS' | 'DONE' | 'WAITED' | 'CANCELED' | 'ERRORED'
*/
export class CancellableTask {
abortController: AbortController | null = null;
cancelToken: AbortController | null = null;
cancellable: boolean = true;
/**
* A promise that resolves once the task completes, and rejects if the task is canceled or errored.
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
*/
complete!: Promise<unknown>;
succeeded: boolean = false;
executed: boolean = false;
private completeResolve: (() => void) | undefined;
private completeReject: (() => void) | undefined;
private loadedSignal: (() => void) | undefined;
private canceledSignal: (() => void) | undefined;
constructor(
private succeededCallback?: () => void,
private loadedCallback?: () => void,
private canceledCallback?: () => void,
private errorCallback?: (error: unknown) => void,
) {
this.init();
}
get running() {
return !!this.abortController;
get loading() {
return !!this.cancelToken;
}
async waitUntilCompletion() {
if (this.succeeded) {
if (this.executed) {
return 'DONE';
}
// The `complete` promise resolves when executed, rejects when canceled/errored.
try {
await this.complete;
const complete = this.complete;
await complete;
return 'WAITED';
} catch {
// expected when canceled
// ignore
}
return 'CANCELED';
}
async waitUntilSucceeded() {
async waitUntilExecution() {
// Keep retrying until the task completes successfully (not canceled)
for (;;) {
try {
if (this.succeeded) {
if (this.executed) {
return 'DONE';
}
await this.complete;
@@ -65,60 +52,59 @@ export class CancellableTask {
}
}
async execute(task: (abortSignal: AbortSignal) => Promise<void>, cancellable: boolean) {
if (this.succeeded) {
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
if (this.executed) {
return 'DONE';
}
// if promise is pending, wait on previous request instead.
if (this.abortController) {
if (!cancellable) {
this.cancellable = false;
}
try {
await this.complete;
return 'WAITED';
} catch {
return 'CANCELED';
if (this.cancelToken) {
// if promise is pending, and preventCancel is requested,
// do not allow transition from prevent cancel to allow cancel.
if (this.cancellable && !cancellable) {
this.cancellable = cancellable;
}
await this.complete;
return 'WAITED';
}
this.cancellable = cancellable;
const abortController = (this.abortController = new AbortController());
const cancelToken = (this.cancelToken = new AbortController());
try {
await task(abortController.signal);
if (abortController.signal.aborted) {
await f(cancelToken.signal);
if (cancelToken.signal.aborted) {
return 'CANCELED';
}
this.#transitionToSucceeded();
return 'SUCCESS';
this.#transitionToExecuted();
return 'LOADED';
} catch (error) {
if (abortController.signal.aborted) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).name === 'AbortError') {
// abort error is not treated as an error, but as a cancellation.
return 'CANCELED';
}
this.#transitionToErrored(error);
return 'ERRORED';
} finally {
if (this.abortController === abortController) {
this.abortController = null;
}
this.cancelToken = null;
}
}
private init() {
this.abortController = null;
this.succeeded = false;
this.complete = new Promise<void>((resolve, reject) => {
this.completeResolve = resolve;
this.completeReject = reject;
this.cancelToken = null;
this.executed = false;
this.loadedSignal = resolve;
this.canceledSignal = reject;
});
// Suppress unhandled rejection warning
this.complete.catch(() => {});
}
// will reset this job back to the initial state (isLoaded=false, no errors, etc)
async reset() {
this.#transitionToCancelled();
if (this.abortController) {
if (this.cancelToken) {
await this.waitUntilCompletion();
}
this.init();
@@ -129,26 +115,27 @@ export class CancellableTask {
}
#transitionToCancelled() {
if (this.succeeded) {
if (this.executed) {
return;
}
if (!this.cancellable) {
return;
}
this.abortController?.abort();
this.completeReject?.();
this.cancelToken?.abort();
this.canceledSignal?.();
this.init();
this.canceledCallback?.();
}
#transitionToSucceeded() {
this.succeeded = true;
this.completeResolve?.();
this.succeededCallback?.();
#transitionToExecuted() {
this.executed = true;
this.loadedSignal?.();
this.loadedCallback?.();
}
#transitionToErrored(error: unknown) {
this.completeReject?.();
this.cancelToken = null;
this.canceledSignal?.();
this.init();
this.errorCallback?.(error);
}