Skip to content

Commit 78bd212

Browse files
dudina-macapcom6
authored andcommitted
[tasks] fix task pagination race in project view
1 parent f80cd44 commit 78bd212

9 files changed

Lines changed: 213 additions & 108 deletions

frontend/src/lib/components/AssigneeCombobox.svelte

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import { searchUsers } from "$lib/api/users";
33
import type { UserBrief } from "$lib/types/api";
4+
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";
45
56
let {
67
value = $bindable<number | null>(null),
@@ -20,35 +21,33 @@
2021
let loading = $state(false);
2122
let selectedName = $state("");
2223
let timer: ReturnType<typeof setTimeout> | null = null;
23-
let requestSeq = 0;
24+
const requestGuard = createLatestRequestGuard();
2425
2526
$effect(() => {
2627
if (initialName) selectedName = initialName;
2728
});
2829
2930
function doSearch(q: string) {
30-
const seq = ++requestSeq;
3131
if (!q.trim()) {
32+
requestGuard.invalidate();
3233
results = [];
3334
open = false;
3435
loading = false;
3536
return;
3637
}
3738
loading = true;
38-
searchUsers(q, 10)
39-
.then((r) => {
40-
if (seq !== requestSeq) return;
39+
runLatest(requestGuard, () => searchUsers(q, 10), {
40+
onSuccess: (r) => {
4141
results = r.items;
4242
open = true;
43-
})
44-
.catch(() => {
45-
if (seq !== requestSeq) return;
43+
},
44+
onError: () => {
4645
results = [];
47-
})
48-
.finally(() => {
49-
if (seq !== requestSeq) return;
46+
},
47+
onFinally: () => {
5048
loading = false;
51-
});
49+
},
50+
});
5251
}
5352
5453
function handleInput(e: Event) {
@@ -59,7 +58,7 @@
5958
}
6059
6160
function select(user: UserBrief) {
62-
requestSeq++;
61+
requestGuard.invalidate();
6362
value = user.id;
6463
selectedName = user.name;
6564
query = "";
@@ -68,7 +67,7 @@
6867
}
6968
7069
function clear() {
71-
requestSeq++;
70+
requestGuard.invalidate();
7271
value = null;
7372
selectedName = "";
7473
query = "";

frontend/src/lib/components/TaskFilters.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@
5353
if (!mounted) return;
5454
const value = draft;
5555
const timer = setTimeout(() => {
56-
untrack(() => onSearchChange?.(value));
56+
untrack(() => {
57+
if (value !== searchQuery) {
58+
onSearchChange?.(value);
59+
}
60+
});
5761
}, 250);
5862
return () => clearTimeout(timer);
5963
});

frontend/src/lib/latest-request.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export function createLatestRequestGuard() {
2+
let version = 0;
3+
4+
return {
5+
next(): number {
6+
return ++version;
7+
},
8+
isLatest(token: number): boolean {
9+
return token === version;
10+
},
11+
invalidate(): void {
12+
version++;
13+
},
14+
};
15+
}
16+
17+
export type LatestRequestGuard = ReturnType<typeof createLatestRequestGuard>;
18+
19+
type RunLatestHandlers<T> = {
20+
onSuccess: (value: T) => void;
21+
onError?: (error: unknown) => void;
22+
onFinally?: () => void;
23+
};
24+
25+
export function runLatest<T>(
26+
guard: LatestRequestGuard,
27+
work: () => Promise<T>,
28+
handlers: RunLatestHandlers<T>,
29+
): void {
30+
const token = guard.next();
31+
work()
32+
.then((value) => {
33+
if (!guard.isLatest(token)) return;
34+
handlers.onSuccess(value);
35+
})
36+
.catch((error) => {
37+
if (!guard.isLatest(token)) return;
38+
handlers.onError?.(error);
39+
})
40+
.finally(() => {
41+
if (!guard.isLatest(token)) return;
42+
handlers.onFinally?.();
43+
});
44+
}

frontend/src/lib/pages/admin-projects.svelte

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import SearchIcon from "@lucide/svelte/icons/search";
1515
import XIcon from "@lucide/svelte/icons/x";
1616
import type { Project } from "$lib/types/api";
17+
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";
1718
1819
let projects = $state<Project[]>([]);
1920
let loading = $state(true);
@@ -33,6 +34,8 @@
3334
let editRepoUrl = $state("");
3435
let editSaving = $state(false);
3536
37+
const requestGuard = createLatestRequestGuard();
38+
3639
function handleSearchInput(value: string) {
3740
searchTerm = value;
3841
if (searchTimeout) clearTimeout(searchTimeout);
@@ -57,17 +60,24 @@
5760
return new Date(dateStr).toLocaleDateString();
5861
}
5962
60-
async function load() {
63+
function load() {
6164
loading = true;
6265
error = "";
63-
try {
64-
const res = await listProjects(100, 0, debouncedSearch || undefined);
65-
projects = res.items;
66-
} catch (e: any) {
67-
error = e?.message || "Failed to load projects";
68-
} finally {
69-
loading = false;
70-
}
66+
runLatest(
67+
requestGuard,
68+
() => listProjects(100, 0, debouncedSearch || undefined),
69+
{
70+
onSuccess: (res) => {
71+
projects = res.items;
72+
},
73+
onError: (e) => {
74+
error = (e as Error)?.message || "Failed to load projects";
75+
},
76+
onFinally: () => {
77+
loading = false;
78+
},
79+
},
80+
);
7181
}
7282
7383
function openCreate() {

frontend/src/lib/pages/admin-users.svelte

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import * as Badge from "$lib/components/ui/badge";
1010
import * as Dialog from "$lib/components/ui/dialog";
1111
import type { User } from "$lib/types/api";
12+
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";
1213
1314
const PAGE_SIZE = 20;
1415
@@ -29,6 +30,8 @@
2930
3031
let activatingId = $state<number | null>(null);
3132
33+
const requestGuard = createLatestRequestGuard();
34+
3235
let totalPages = $derived(Math.max(1, Math.ceil(total / PAGE_SIZE)));
3336
3437
const statusBadgeColors: Record<string, string> = {
@@ -50,24 +53,32 @@
5053
return new Date(dateStr).toLocaleDateString();
5154
}
5255
53-
async function loadUsers() {
56+
function loadUsers() {
5457
loading = true;
5558
error = "";
56-
try {
57-
const res = await listUsers({
58-
status: filterStatus || undefined,
59-
role: filterRole || undefined,
60-
limit: PAGE_SIZE,
61-
offset: (page - 1) * PAGE_SIZE,
62-
});
63-
users = res.items;
64-
total = res.total;
65-
} catch (e: any) {
66-
error = e?.message || "Failed to load users";
67-
users = [];
68-
} finally {
69-
loading = false;
70-
}
59+
runLatest(
60+
requestGuard,
61+
() =>
62+
listUsers({
63+
status: filterStatus || undefined,
64+
role: filterRole || undefined,
65+
limit: PAGE_SIZE,
66+
offset: (page - 1) * PAGE_SIZE,
67+
}),
68+
{
69+
onSuccess: (res) => {
70+
users = res.items;
71+
total = res.total;
72+
},
73+
onError: (e) => {
74+
error = (e as Error)?.message || "Failed to load users";
75+
users = [];
76+
},
77+
onFinally: () => {
78+
loading = false;
79+
},
80+
},
81+
);
7182
}
7283
7384
function onFilterChange() {

frontend/src/lib/pages/dashboard-personal.svelte

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type { Task, Project } from "$lib/types/api";
1111
import { ACTIVE_STATUSES } from "$lib/types/api";
1212
import { getSnapshot, saveSnapshot } from "$lib/stores/task-filters.svelte";
13+
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";
1314
1415
let { params = {} }: { params?: Record<string, string> } = $props();
1516
@@ -36,6 +37,8 @@
3637
sort = saved.sort;
3738
}
3839
40+
const requestGuard = createLatestRequestGuard();
41+
3942
function toggleStatus(s: string) {
4043
if (filterStatuses.includes(s)) {
4144
filterStatuses = filterStatuses.filter((x) => x !== s);
@@ -94,24 +97,30 @@
9497
filters.priorities = filterPriorities.join(",");
9598
if (searchQuery) filters.search = searchQuery;
9699
97-
Promise.all([
98-
listProjects(100, 0).then((r) => {
99-
projects = r.items;
100-
}),
101-
getMyTasks({ limit: 50, ...filters }).then((res) => {
102-
const currentUserId = getUser()?.id;
103-
createdTasks = res.items.filter((t) => t.author.id === currentUserId);
104-
assignedTasks = res.items.filter(
105-
(t) => t.assignee?.id === currentUserId,
106-
);
107-
}),
108-
])
109-
.catch((e) => {
110-
error = e.message || "Failed to load tasks";
111-
})
112-
.finally(() => {
113-
loading = false;
114-
});
100+
runLatest(
101+
requestGuard,
102+
() =>
103+
Promise.all([
104+
listProjects(100, 0).then((r) => r.items),
105+
getMyTasks({ limit: 50, ...filters }).then((res) => res.items),
106+
]),
107+
{
108+
onSuccess: ([projectItems, taskItems]) => {
109+
projects = projectItems;
110+
const currentUserId = getUser()?.id;
111+
createdTasks = taskItems.filter((t) => t.author.id === currentUserId);
112+
assignedTasks = taskItems.filter(
113+
(t) => t.assignee?.id === currentUserId,
114+
);
115+
},
116+
onError: (e) => {
117+
error = (e as Error).message || "Failed to load tasks";
118+
},
119+
onFinally: () => {
120+
loading = false;
121+
},
122+
},
123+
);
115124
});
116125
</script>
117126

frontend/src/lib/pages/dashboard-tasks.svelte

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import type { Project, Task } from "$lib/types/api";
1313
import { ACTIVE_STATUSES } from "$lib/types/api";
1414
import { getSnapshot, saveSnapshot } from "$lib/stores/task-filters.svelte";
15+
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";
1516
1617
let { params = {} }: { params?: Record<string, string> } = $props();
1718
@@ -40,6 +41,8 @@
4041
offset = saved.offset;
4142
}
4243
44+
const requestGuard = createLatestRequestGuard();
45+
4346
function toggleStatus(s: string) {
4447
if (filterStatuses.includes(s)) {
4548
filterStatuses = filterStatuses.filter((x) => x !== s);
@@ -83,21 +86,27 @@
8386
filters.priorities = filterPriorities.join(",");
8487
if (searchQuery) filters.search = searchQuery;
8588
86-
Promise.all([
87-
listProjects(100, 0).then((r) => {
88-
projects = r.items;
89-
}),
90-
listTasks(filters).then((r) => {
91-
tasks = r.items;
92-
total = r.total;
93-
}),
94-
])
95-
.catch((e) => {
96-
error = e.message || "Failed to load tasks";
97-
})
98-
.finally(() => {
99-
loading = false;
100-
});
89+
runLatest(
90+
requestGuard,
91+
() =>
92+
Promise.all([
93+
listProjects(100, 0).then((r) => r.items),
94+
listTasks(filters).then((r) => ({ items: r.items, total: r.total })),
95+
]),
96+
{
97+
onSuccess: ([projectItems, taskRes]) => {
98+
projects = projectItems;
99+
tasks = taskRes.items;
100+
total = taskRes.total;
101+
},
102+
onError: (e) => {
103+
error = (e as Error).message || "Failed to load tasks";
104+
},
105+
onFinally: () => {
106+
loading = false;
107+
},
108+
},
109+
);
101110
}
102111
103112
function resetFilters() {

0 commit comments

Comments
 (0)