Compare commits

..

1 Commits

Author SHA1 Message Date
midzelis
b049d0fa48 refactor(web): fixed-size scroll plane for timeline virtual scroll
Replaces the timeline's growing virtual scroll height with a fixed
500K-pixel scroll plane that recycles around an anchor month. Removes
the browser max-height ceiling and the O(N) layout cascade that ran on
every month height change.

- Months are positioned by planeTop, derived on demand by walking
  outward from the anchor in positionMonthsOnPlane.
- Soft repoint (trackAnchorToViewportTop) runs on every scroll; hard
  repoint (recenterPlane) slides the plane back toward PLANE_CENTER on
  idle or near plane edges.
- Height changes shift the anchor instead of scrollTop, fixing Safari
  momentum-scroll stutter when a viewport-top month settles.

Change-Id: I39cb61e7c4ff6cd5b0d59a7cc9c65b4e6a6a6964
2026-04-07 13:55:10 +00:00
13 changed files with 632 additions and 406 deletions

View File

@@ -127,9 +127,27 @@
const scrollY = $derived(
toScrollFromTimelineMonthPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
);
const timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight);
const relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
const relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
const estimateMonthHeight = (assetCount: number) => {
const viewportWidth = timelineManager.viewportWidth;
const rowHeight = timelineManager.rowHeight;
const headerHeight = timelineManager.headerHeight;
if (viewportWidth === 0) {
return headerHeight + rowHeight;
}
const rows = Math.ceil(((3 / 2) * assetCount * rowHeight * (7 / 10)) / viewportWidth);
return headerHeight + Math.max(1, rows) * rowHeight;
};
const totalEstimatedHeight = $derived.by(() => {
let total = timelineManager.topSectionHeight + timelineManager.bottomSectionHeight;
for (const month of timelineManager.scrubberMonths) {
total += estimateMonthHeight(month.assetCount);
}
return total;
});
const relativeTopOffset = $derived(toScrollY(timelineManager.topSectionHeight / totalEstimatedHeight));
const relativeBottomOffset = $derived(toScrollY(timelineManager.bottomSectionHeight / totalEstimatedHeight));
type Segment = {
count: number;
@@ -154,7 +172,7 @@
const reversed = [...months].reverse();
for (const scrubMonth of reversed) {
const scrollBarPercentage = scrubMonth.height / timelineFullHeight;
const scrollBarPercentage = estimateMonthHeight(scrubMonth.assetCount) / totalEstimatedHeight;
const segment = {
top,
@@ -493,7 +511,13 @@
<svelte:window
bind:innerHeight={windowHeight}
onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
onmousemove={(e) => {
if (isDragging && (e.buttons & 1) === 0) {
handleMouseEvent({ clientY: e.clientY, isDragging: false });
} else if (isDragging || isHover) {
handleMouseEvent({ clientY: e.clientY });
}
}}
onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
/>

View File

@@ -13,16 +13,17 @@
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import type { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
import type { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
import { clamp } from 'lodash-es';
import { DateTime } from 'luxon';
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
@@ -101,6 +102,14 @@
let scrubberWidth = $state(0);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const topSectionPlaneTop = $derived(
timelineManager.months.length > 0 ? timelineManager.months[0].planeTop - timelineManager.topSectionHeight : 0,
);
const leadoutPlaneTop = $derived(
timelineManager.months.length > 0
? timelineManager.months.at(-1)!.planeTop + timelineManager.months.at(-1)!.height
: timelineManager.topSectionHeight,
);
const maxMd = $derived(mediaQueryManager.maxMd);
const usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
@@ -169,18 +178,15 @@
const scrollAndLoadAsset = async (assetId: string) => {
try {
// This flag prevents layout deferral to fix scroll positioning issues.
// When layouts are deferred and we scroll to an asset at the end of the timeline,
// we can calculate the asset's position, but the scrollableElement's scrollHeight
// hasn't been updated yet to reflect the new layout. This creates a mismatch that
// breaks scroll positioning. By disabling layout deferral in this case, we maintain
// the performance benefits of deferred layouts while still supporting deep linking
// to assets at the end of the timeline.
timelineManager.isScrollingOnLoad = true;
const timelineMonth = await timelineManager.findTimelineMonthForAsset({ id: assetId });
if (!timelineMonth) {
return false;
}
const monthIndex = timelineManager.months.indexOf(timelineMonth);
if (monthIndex !== -1) {
timelineManager.jumpToMonth({ monthIndex, fractionInMonth: 0 });
}
scrollToAssetPosition(assetId, timelineMonth);
return true;
} finally {
@@ -213,7 +219,6 @@
scrolled = await scrollAndLoadAsset(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
timelineManager.scrollTo(0);
} else if (scrollTarget) {
await tick();
@@ -263,102 +268,105 @@
}
});
const scrollToSegmentPercentage = (segmentTop: number, segmentHeight: number, timelineMonthScrollPercent: number) => {
const topOffset = segmentTop;
const maxScrollPercent = timelineManager.maxScrollPercent;
const delta = segmentHeight * timelineMonthScrollPercent;
const scrollToTop = (topOffset + delta) * maxScrollPercent;
timelineManager.scrollTo(scrollToTop);
};
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
const onScrub: ScrubberListener = (scrubberData) => {
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
const { scrubberMonth, scrubberMonthScrollPercent } = scrubberData;
const leadIn = scrubberMonth === 'lead-in';
const leadOut = scrubberMonth === 'lead-out';
const noMonth = !scrubberMonth;
// For small timelines, use linear percentage for bi-directional sync with handleTimelineScroll
if (timelineManager.limitedScroll) {
const maxScroll = timelineManager.planeHeight - timelineManager.viewportHeight;
timelineManager.scrollTo(scrubberData.overallScrollPercent * maxScroll);
return;
}
if (noMonth || timelineManager.limitedScroll) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = timelineManager.maxScrollPercent;
const offset = maxScroll * overallScrollPercent * timelineManager.totalViewerHeight;
timelineManager.scrollTo(offset);
} else if (leadIn) {
scrollToSegmentPercentage(0, timelineManager.topSectionHeight, scrubberMonthScrollPercent);
} else if (leadOut) {
scrollToSegmentPercentage(
timelineManager.topSectionHeight + timelineManager.bodySectionHeight,
timelineManager.bottomSectionHeight,
scrubberMonthScrollPercent,
);
} else {
const timelineMonth = timelineManager.months.find(
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
);
if (!timelineMonth) {
if (!scrubberMonth) {
if (timelineManager.months.length === 0) {
return;
}
scrollToSegmentPercentage(timelineMonth.top, timelineMonth.height, scrubberMonthScrollPercent);
if (scrubberData.overallScrollPercent <= 0) {
const firstMonth = timelineManager.months[0];
timelineManager.scrollTo(firstMonth.planeTop - timelineManager.topSectionHeight);
} else if (scrubberData.overallScrollPercent >= 1) {
const lastMonth = timelineManager.months.at(-1)!;
timelineManager.scrollTo(
lastMonth.planeTop + lastMonth.height + timelineManager.bottomSectionHeight - timelineManager.viewportHeight,
);
}
return;
}
if (scrubberMonth === 'lead-in') {
if (timelineManager.months.length > 0) {
const firstMonth = timelineManager.months[0];
timelineManager.scrollTo(
firstMonth.planeTop - timelineManager.topSectionHeight * (1 - scrubberMonthScrollPercent),
);
}
return;
}
if (scrubberMonth === 'lead-out') {
if (timelineManager.months.length > 0) {
const lastMonth = timelineManager.months.at(-1)!;
timelineManager.scrollTo(
lastMonth.planeTop + lastMonth.height + timelineManager.bottomSectionHeight * scrubberMonthScrollPercent,
);
}
return;
}
const monthIndex = timelineManager.months.findIndex(
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
);
if (monthIndex !== -1) {
timelineManager.jumpToMonth({ monthIndex, fractionInMonth: scrubberMonthScrollPercent });
}
};
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
const handleTimelineScroll = () => {
if (!scrollableElement) {
const maxScroll = timelineManager.planeHeight - timelineManager.viewportHeight;
timelineScrollPercent = maxScroll > 0 ? clamp(timelineManager.visibleWindow.top / maxScroll, 0, 1) : 0;
// For small timelines, use linear percentage positioning for smooth bi-directional sync
if (timelineManager.limitedScroll) {
viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0;
return;
}
if (timelineManager.limitedScroll) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = timelineManager.maxScroll;
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
const intersection = timelineManager.viewportTopMonthIntersection;
if (!intersection?.month) {
viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0;
} else {
timelineScrollPercent = 0;
let top = scrollableElement.scrollTop;
let maxScrollPercent = timelineManager.maxScrollPercent;
const monthsLength = timelineManager.months.length;
for (let i = -1; i < monthsLength + 1; i++) {
let timelineMonth: ViewportTopMonth;
let timelineMonthHeight: number;
if (i === -1) {
// lead-in
timelineMonth = 'lead-in';
timelineMonthHeight = timelineManager.topSectionHeight;
} else if (i === monthsLength) {
// lead-out
timelineMonth = 'lead-out';
timelineMonthHeight = timelineManager.bottomSectionHeight;
} else {
timelineMonth = timelineManager.months[i].yearMonth;
timelineMonthHeight = timelineManager.months[i].height;
}
let next = top - timelineMonthHeight * maxScrollPercent;
// instead of checking for < 0, add a little wiggle room for subpixel resolution
if (next < -1 && timelineMonth) {
viewportTopMonth = timelineMonth;
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
viewportTopMonthScrollPercent = Math.max(0, top / (timelineMonthHeight * maxScrollPercent));
// compensate for lost precision/rounding errors advance to the next bucket, if present
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
viewportTopMonthScrollPercent = 0;
}
break;
}
top = next;
}
return;
}
const firstMonth = timelineManager.months[0];
if (firstMonth && timelineManager.visibleWindow.top < firstMonth.planeTop) {
viewportTopMonth = 'lead-in';
const topSectionTop = firstMonth.planeTop - timelineManager.topSectionHeight;
viewportTopMonthScrollPercent =
timelineManager.topSectionHeight > 0
? Math.max(0, (timelineManager.visibleWindow.top - topSectionTop) / timelineManager.topSectionHeight)
: 0;
return;
}
const lastMonth = timelineManager.months.at(-1)!;
const contentBottom = lastMonth.planeTop + lastMonth.height;
if (timelineManager.visibleWindow.top >= contentBottom && timelineManager.bottomSectionHeight > 0) {
viewportTopMonth = 'lead-out';
viewportTopMonthScrollPercent = Math.min(
1,
(timelineManager.visibleWindow.bottom - contentBottom) / timelineManager.bottomSectionHeight,
);
return;
}
viewportTopMonth = intersection.month.yearMonth;
viewportTopMonthScrollPercent = intersection.viewportTopRatioInMonth;
};
const handleSelectAsset = (asset: TimelineAsset) => {
@@ -594,6 +602,8 @@
{viewportTopMonthScrollPercent}
{viewportTopMonth}
{onScrub}
startScrub={() => timelineManager.setScrubbing(true)}
stopScrub={() => timelineManager.setScrubbing(false)}
bind:scrubberWidth
onScrubKeyDown={(evt) => {
evt.preventDefault();
@@ -622,13 +632,13 @@
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={timelineManager.viewportWidth}
bind:this={scrollableElement}
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
onscroll={() => (timelineManager.updateSlidingWindow(), handleTimelineScroll(), updateIsScrolling())}
>
<section
bind:this={timelineElement}
id="virtual-timeline"
class:invisible
style:height={timelineManager.totalViewerHeight + 'px'}
style:height={timelineManager.planeHeight + 'px'}
>
<section
bind:clientHeight={timelineManager.topSectionHeight}
@@ -636,6 +646,7 @@
style:position="absolute"
style:left="0"
style:right="0"
style:transform={`translate3d(0,${topSectionPlaneTop}px,0)`}
>
{@render children?.()}
{#if isEmpty}
@@ -646,70 +657,66 @@
{#each timelineManager.months as timelineMonth (timelineMonth.viewId)}
{@const isInOrNearViewport = timelineMonth.isInOrNearViewport}
{@const absoluteHeight = timelineMonth.top}
{@const absoluteHeight = timelineMonth.planeTop}
{#if !timelineMonth.isLoaded}
{#if isInOrNearViewport}
<div
style:height={timelineMonth.height + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<Skeleton {invisible} height={timelineMonth.height} title={timelineMonth.title} />
</div>
{:else if isInOrNearViewport}
<div
class="timeline-month"
style:height={timelineMonth.height + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<Month
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{timelineMonth}
manager={timelineManager}
onTimelineDaySelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
} else {
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
}
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
return;
}
void onSelectAssets(asset);
}}
onMouseEvent={() => handleSelectAssetCandidates(asset)}
onPreview={isSelectionMode || assetInteraction.selectionActive
? (asset) => void navigate({ targetRoute: 'current', assetId: asset.id })
: undefined}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</Month>
{#if timelineMonth.isLoaded}
<div class="timeline-month" style:height={timelineMonth.height + 'px'} style:width="100%">
<Month
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{timelineMonth}
manager={timelineManager}
onTimelineDaySelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
} else {
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
}
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
return;
}
void onSelectAssets(asset);
}}
onMouseEvent={() => handleSelectAssetCandidates(asset)}
onPreview={isSelectionMode || assetInteraction.selectionActive
? (asset) => void navigate({ targetRoute: 'current', assetId: asset.id })
: undefined}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</Month>
</div>
{:else}
<Skeleton {invisible} height={timelineMonth.height} title={timelineMonth.title} />
{/if}
</div>
{/if}
{/each}
@@ -719,7 +726,7 @@
style:position="absolute"
style:left="0"
style:right="0"
style:transform={`translate3d(0,${timelineManager.topSectionHeight + timelineManager.bodySectionHeight}px,0)`}
style:transform={`translate3d(0,${leadoutPlaneTop}px,0)`}
></div>
</section>
</section>

View File

@@ -33,17 +33,7 @@
:global(.dark) [data-skeleton] {
background-image: url('/dark_skeleton.png');
}
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
[data-skeleton] {
visibility: hidden;
animation:
0s linear 0.1s forwards delayedVisibility,
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.invisible [data-skeleton] {
visibility: hidden !important;
}

View File

@@ -6,11 +6,30 @@ type LayoutOptions = {
gap: number;
};
export abstract class VirtualScrollManager {
topSectionHeight = $state(0);
static readonly PLANE_SIZE = 500_000;
static readonly PLANE_CENTER = 250_000;
planeHeight = $state(VirtualScrollManager.PLANE_SIZE);
#topSectionHeight = $state(0);
bodySectionHeight = $state(0);
bottomSectionHeight = $state(0);
totalViewerHeight = $derived.by(() => this.topSectionHeight + this.bodySectionHeight + this.bottomSectionHeight);
get topSectionHeight() {
return this.#topSectionHeight;
}
set topSectionHeight(value: number) {
if (this.#topSectionHeight === value) {
return;
}
const oldValue = this.#topSectionHeight;
this.#topSectionHeight = value;
this.onTopSectionHeightChanged(oldValue, value);
}
protected onTopSectionHeightChanged(_oldHeight: number, _newHeight: number) {}
visibleWindow = $derived.by(() => ({
top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight,

View File

@@ -42,8 +42,8 @@ function calculateViewportProximity(regionTop: number, regionBottom: number, win
export function updateTimelineMonthViewportProximity(timelineManager: TimelineManager, month: TimelineMonth) {
const proximity = calculateViewportProximity(
month.top,
month.top + month.height,
month.planeTop,
month.planeTop + month.height,
timelineManager.visibleWindow.top,
timelineManager.visibleWindow.bottom,
);

View File

@@ -25,7 +25,7 @@ export async function loadFromTimeBuckets(
{ signal },
);
if (!bucketResponse) {
if (!bucketResponse || signal.aborted) {
return;
}
@@ -38,7 +38,7 @@ export async function loadFromTimeBuckets(
},
{ signal },
);
if (!albumAssets) {
if (!albumAssets || signal.aborted) {
return;
}
for (const id of albumAssets.id) {

View File

@@ -152,6 +152,6 @@ export class TimelineDay {
}
get absoluteTimelineDayTop() {
return this.timelineMonth.top + this.#top;
return this.timelineMonth.planeTop + this.#top;
}
}

View File

@@ -145,7 +145,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 +638,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 () => {

View File

@@ -70,9 +70,17 @@ export class TimelineManager extends VirtualScrollManager {
months: TimelineMonth[] = $state([]);
albumAssets: Set<string> = new SvelteSet();
scrubberMonths: ScrubberMonth[] = $state([]);
scrubberTimelineHeight: number = $state(0);
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
limitedScroll = $derived(this.maxScrollPercent < 0.5);
anchorMonthIndex: number = -1;
anchorPlaneTop: number = VirtualScrollManager.PLANE_CENTER;
#recenterTimer: ReturnType<typeof setTimeout> | undefined;
#recentering = false;
#scrubbing = false;
limitedScroll = $derived(
this.months.length > 0 &&
this.totalViewerHeight <= VirtualScrollManager.PLANE_SIZE &&
this.viewportHeight > this.months.at(-1)!.height + this.bottomSectionHeight,
);
initTask = new CancellableTask(
() => {
this.isInitialized = true;
@@ -122,6 +130,22 @@ export class TimelineManager extends VirtualScrollManager {
return this.#scrollableElement?.scrollTop ?? 0;
}
protected override onTopSectionHeightChanged(oldHeight: number, newHeight: number) {
if (this.anchorMonthIndex === -1 || this.months.length === 0) {
return;
}
const delta = newHeight - oldHeight;
const scrollTopBefore = this.scrollTop;
this.anchorPlaneTop += delta;
this.positionMonthsOnPlane();
// If the user is still inside the lead-in, no month content is visible to keep
// pinned, and shifting scrollTop would push them past the lead-in.
if (scrollTopBefore <= oldHeight) {
return;
}
this.scrollBy(delta);
}
set scrollableElement(element: HTMLElement | undefined) {
this.#scrollableElement = element;
}
@@ -189,7 +213,7 @@ export class TimelineManager extends VirtualScrollManager {
return 0;
}
const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top;
const bottomOfMonth = month.top + month.height;
const bottomOfMonth = month.planeTop + month.height;
const bottomOfMonthInViewport = bottomOfMonth - this.visibleWindow.top;
return clamp(bottomOfMonthInViewport / windowHeight, 0, 1);
}
@@ -198,7 +222,7 @@ export class TimelineManager extends VirtualScrollManager {
if (!month) {
return 0;
}
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
return clamp((this.visibleWindow.top - month.planeTop) / month.height, 0, 1);
}
override updateViewportProximities() {
@@ -238,6 +262,177 @@ export class TimelineManager extends VirtualScrollManager {
}
}
/**
* Derives every month's planeTop by walking outward from the anchor. The anchor
* stays pinned at anchorPlaneTop, so any height change elsewhere shifts months
* away from the anchor — content at the viewport-top stays stable as long as
* trackAnchorToViewportTop ran beforehand.
*/
positionMonthsOnPlane() {
if (this.months.length === 0 || this.anchorMonthIndex === -1) {
return;
}
const anchor = this.months[this.anchorMonthIndex];
anchor.planeTop = this.anchorPlaneTop;
let cursorBelow = this.anchorPlaneTop + anchor.height;
for (let i = this.anchorMonthIndex + 1; i < this.months.length; i++) {
const month = this.months[i];
month.planeTop = cursorBelow;
cursorBelow += month.height;
}
let cursorAbove = this.anchorPlaneTop;
for (let i = this.anchorMonthIndex - 1; i >= 0; i--) {
const month = this.months[i];
cursorAbove -= month.height;
month.planeTop = cursorAbove;
}
const lastMonth = this.months.at(-1)!;
const contentBottom = lastMonth.planeTop + lastMonth.height + this.bottomSectionHeight;
this.planeHeight = Math.min(VirtualScrollManager.PLANE_SIZE, contentBottom);
}
/** Soft repoint: change the anchor month without moving any planeTop or scrollTop. */
trackAnchorToViewportTop() {
if (this.months.length === 0) {
return;
}
const visibleTop = this.visibleWindow.top;
let newAnchorIndex = -1;
for (let i = 0; i < this.months.length; i++) {
const month = this.months[i];
if (month.planeTop + month.height > visibleTop) {
newAnchorIndex = i;
break;
}
}
if (newAnchorIndex === -1 || newAnchorIndex === this.anchorMonthIndex) {
return;
}
this.anchorMonthIndex = newAnchorIndex;
this.anchorPlaneTop = this.months[newAnchorIndex].planeTop;
}
// Each scroll event resets this timer, so a brief pause in scrolling recenters
// the plane. Continuous scrolling near a plane edge bypasses it via isNearPlaneEdge.
static readonly RECENTER_DEBOUNCE_MS = 50;
static readonly PLANE_EDGE_THRESHOLD = 50_000;
#scheduleRecenter() {
clearTimeout(this.#recenterTimer);
this.#recenterTimer = setTimeout(() => {
this.#recenterTimer = undefined;
this.recenterPlane();
}, TimelineManager.RECENTER_DEBOUNCE_MS);
}
isNearPlaneEdge(): boolean {
return (
this.scrollTop < TimelineManager.PLANE_EDGE_THRESHOLD ||
this.scrollTop > VirtualScrollManager.PLANE_SIZE - TimelineManager.PLANE_EDGE_THRESHOLD
);
}
/**
* Hard repoint: slide every planeTop and scrollTop to pull the anchor back
* toward PLANE_CENTER, or pin month 0 to topSectionHeight when it fits.
*/
recenterPlane() {
clearTimeout(this.#recenterTimer);
this.#recenterTimer = undefined;
if (this.#recentering || this.months.length === 0 || this.anchorMonthIndex === -1) {
return;
}
const viewportTopMonth = this.months[this.anchorMonthIndex];
if (!viewportTopMonth) {
return;
}
// Pin months[0] when the visible content still fits on the plane alongside it.
// Only the downward distance is checked because nothing exists above month 0.
// Fall back to PLANE_CENTER recycling only when month 0 no longer fits.
const firstMonth = this.months[0];
const viewportTopOffsetFromFirstMonth = viewportTopMonth.planeTop - firstMonth.planeTop + this.topSectionHeight;
const canPinFirstMonth =
viewportTopOffsetFromFirstMonth + this.viewportHeight <=
VirtualScrollManager.PLANE_SIZE - TimelineManager.PLANE_EDGE_THRESHOLD;
let targetMonth: TimelineMonth;
let targetPlaneTop: number;
if (canPinFirstMonth || this.anchorMonthIndex === 0) {
targetMonth = firstMonth;
targetPlaneTop = this.topSectionHeight;
} else {
targetMonth = viewportTopMonth;
targetPlaneTop = VirtualScrollManager.PLANE_CENTER;
}
const monthIndex = this.months.indexOf(targetMonth);
const delta = targetPlaneTop - targetMonth.planeTop;
if (delta === 0) {
return;
}
// Same lead-in guard as onTopSectionHeightChanged.
const preserveScrollTop = this.scrollTop <= this.topSectionHeight;
this.#recentering = true;
try {
for (const month of this.months) {
month.planeTop += delta;
}
this.anchorMonthIndex = monthIndex;
this.anchorPlaneTop = targetPlaneTop;
if (this.#scrollableElement && !preserveScrollTop) {
this.#scrollableElement.scrollTop += delta;
}
const lastMonth = this.months.at(-1)!;
const contentBottom = lastMonth.planeTop + lastMonth.height + this.bottomSectionHeight;
this.planeHeight = Math.min(VirtualScrollManager.PLANE_SIZE, contentBottom);
this.updateSlidingWindow();
} finally {
this.#recentering = false;
}
}
override updateSlidingWindow() {
super.updateSlidingWindow();
if (this.#recentering || this.#scrubbing) {
return;
}
this.trackAnchorToViewportTop();
// Continuous scroll keeps resetting the debounce timer, so if scrollTop is
// already near a plane edge we have to recenter immediately or risk hitting it.
if (this.isNearPlaneEdge()) {
this.recenterPlane();
return;
}
this.#scheduleRecenter();
}
setScrubbing(value: boolean) {
this.#scrubbing = value;
if (!value) {
this.updateSlidingWindow();
}
}
jumpToMonth({ monthIndex, fractionInMonth }: { monthIndex: number; fractionInMonth: number }) {
clearTimeout(this.#recenterTimer);
this.#recenterTimer = undefined;
if (this.months.length === 0) {
return;
}
const month = this.months[monthIndex];
if (!month) {
return;
}
this.anchorMonthIndex = monthIndex;
this.anchorPlaneTop = monthIndex === 0 ? this.topSectionHeight : VirtualScrollManager.PLANE_CENTER;
this.positionMonthsOnPlane();
this.scrollTo(month.planeTop + fractionInMonth * month.height);
}
async #initializeTimelineMonths() {
const timebuckets = await getTimeBuckets({
...authManager.params,
@@ -280,6 +475,7 @@ export class TimelineManager extends VirtualScrollManager {
async #init(options: TimelineManagerOptions) {
this.isInitialized = false;
this.months = [];
this.anchorMonthIndex = -1;
this.albumAssets.clear();
await this.initTask.execute(async () => {
this.#options = options;
@@ -307,8 +503,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;
@@ -324,6 +520,13 @@ export class TimelineManager extends VirtualScrollManager {
for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: changedWidth });
}
if (this.months.length > 0 && this.anchorMonthIndex === -1) {
this.anchorMonthIndex = 0;
this.anchorPlaneTop = this.topSectionHeight;
}
this.positionMonthsOnPlane();
this.updateViewportProximities();
if (changedWidth) {
this.#createScrubberMonths();
@@ -336,9 +539,7 @@ export class TimelineManager extends VirtualScrollManager {
year: month.yearMonth.year,
month: month.yearMonth.month,
title: month.title,
height: month.height,
}));
this.scrubberTimelineHeight = this.totalViewerHeight;
}
async loadTimelineMonth(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
@@ -351,10 +552,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 +573,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;

View File

@@ -36,7 +36,7 @@ export class TimelineMonth {
readonly timelineManager: TimelineManager;
#height: number = $state(0);
#top: number = $state(0);
#planeTop: number = $state(0);
#initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc;
@@ -266,39 +266,36 @@ export class TimelineMonth {
return;
}
const timelineManager = this.timelineManager;
const index = timelineManager.months.indexOf(this);
// Repin the anchor BEFORE positionMonthsOnPlane re-derives planeTops, so the
// recomputation only shifts content above the viewport (invisible to the user).
timelineManager.trackAnchorToViewportTop();
// When this month is the viewport-top one, its photos will reflow as the height
// settles from estimate to actual; capture the user's fractional position so we
// can restore it below and avoid the visible stutter.
const isViewportTopMonth =
timelineManager.anchorMonthIndex !== -1 &&
timelineManager.months[timelineManager.anchorMonthIndex] === this &&
this.#height > 0;
const scrollFractionInMonth = isViewportTopMonth ? (timelineManager.scrollTop - this.#planeTop) / this.#height : 0;
const heightDelta = height - this.#height;
this.#height = height;
const previousTimelineMonth = timelineManager.months[index - 1];
if (previousTimelineMonth) {
const newTop = previousTimelineMonth.#top + previousTimelineMonth.#height;
if (this.#top !== newTop) {
this.#top = newTop;
}
}
if (heightDelta === 0) {
return;
}
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
const timelineMonth = this.timelineManager.months[cursor];
const newTop = timelineMonth.#top + heightDelta;
if (timelineMonth.#top !== newTop) {
timelineMonth.#top = newTop;
}
// Shift the anchor instead of scrollTop — touching scrollTop here fights
// native scroll momentum on Safari and visibly stutters.
if (isViewportTopMonth && scrollFractionInMonth > 0) {
timelineManager.anchorPlaneTop -= heightDelta * scrollFractionInMonth;
}
if (!timelineManager.viewportTopMonthIntersection) {
return;
}
const { month, monthBottomViewportRatio, viewportTopRatioInMonth } = timelineManager.viewportTopMonthIntersection;
const currentIndex = month ? timelineManager.months.indexOf(month) : -1;
if (!month || currentIndex <= 0 || index > currentIndex) {
return;
}
if (index < currentIndex || monthBottomViewportRatio < 1) {
timelineManager.scrollBy(heightDelta);
} else if (index === currentIndex) {
const scrollTo = this.top + height * viewportTopRatioInMonth;
timelineManager.scrollTo(scrollTo);
timelineManager.positionMonthsOnPlane();
// Async loads change heights without going through updateSlidingWindow, so the
// near-edge check needs to run here too.
if (timelineManager.isNearPlaneEdge()) {
timelineManager.recenterPlane();
}
}
@@ -306,8 +303,12 @@ export class TimelineMonth {
return this.#height;
}
get top(): number {
return this.#top + this.timelineManager.topSectionHeight;
get planeTop(): number {
return this.#planeTop;
}
set planeTop(value: number) {
this.#planeTop = value;
}
#handleLoadError(error: unknown) {
@@ -337,7 +338,7 @@ export class TimelineMonth {
return;
}
return {
top: this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight,
top: this.planeTop + group.top + viewerAsset.position.top + this.timelineManager.headerHeight,
height: viewerAsset.position.height,
};
}

View File

@@ -79,7 +79,6 @@ export interface UpdateStackAssets {
export type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
export type ScrubberMonth = {
height: number;
assetCount: number;
year: number;
month: number;

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();

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,15 +52,17 @@ 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;
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;
}
try {
await this.complete;
@@ -83,42 +72,43 @@ export class CancellableTask {
}
}
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 +119,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);
}