Skip to content

Commit 38363ac

Browse files
dudina-macapcom6
authored andcommitted
[tasks] fix task pagination race in project view
1 parent c733109 commit 38363ac

9 files changed

Lines changed: 201 additions & 102 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
@@ -9,6 +9,7 @@
99
import * as Card from "$lib/components/ui/card";
1010
import type { Task, Project } from "$lib/types/api";
1111
import { ACTIVE_STATUSES } from "$lib/types/api";
12+
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";
1213
1314
let { params = {} }: { params?: Record<string, string> } = $props();
1415
@@ -25,6 +26,8 @@
2526
let searchQuery = $state("");
2627
let sort = $state("-created_at");
2728
29+
const requestGuard = createLatestRequestGuard();
30+
2831
function toggleStatus(s: string) {
2932
if (filterStatuses.includes(s)) {
3033
filterStatuses = filterStatuses.filter((x) => x !== s);
@@ -73,24 +76,30 @@
7376
filters.priorities = filterPriorities.join(",");
7477
if (searchQuery) filters.search = searchQuery;
7578
76-
Promise.all([
77-
listProjects(100, 0).then((r) => {
78-
projects = r.items;
79-
}),
80-
getMyTasks({ limit: 50, ...filters }).then((res) => {
81-
const currentUserId = getUser()?.id;
82-
createdTasks = res.items.filter((t) => t.author.id === currentUserId);
83-
assignedTasks = res.items.filter(
84-
(t) => t.assignee?.id === currentUserId,
85-
);
86-
}),
87-
])
88-
.catch((e) => {
89-
error = e.message || "Failed to load tasks";
90-
})
91-
.finally(() => {
92-
loading = false;
93-
});
79+
runLatest(
80+
requestGuard,
81+
() =>
82+
Promise.all([
83+
listProjects(100, 0).then((r) => r.items),
84+
getMyTasks({ limit: 50, ...filters }).then((res) => res.items),
85+
]),
86+
{
87+
onSuccess: ([projectItems, taskItems]) => {
88+
projects = projectItems;
89+
const currentUserId = getUser()?.id;
90+
createdTasks = taskItems.filter((t) => t.author.id === currentUserId);
91+
assignedTasks = taskItems.filter(
92+
(t) => t.assignee?.id === currentUserId,
93+
);
94+
},
95+
onError: (e) => {
96+
error = (e as Error).message || "Failed to load tasks";
97+
},
98+
onFinally: () => {
99+
loading = false;
100+
},
101+
},
102+
);
94103
});
95104
</script>
96105

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

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import { Button } from "$lib/components/ui/button";
1212
import type { Project, Task } from "$lib/types/api";
1313
import { ACTIVE_STATUSES } from "$lib/types/api";
14+
import { createLatestRequestGuard, runLatest } from "$lib/latest-request";
1415
1516
let { params = {} }: { params?: Record<string, string> } = $props();
1617
@@ -28,6 +29,8 @@
2829
let offset = $state(0);
2930
const limit = 50;
3031
32+
const requestGuard = createLatestRequestGuard();
33+
3134
function toggleStatus(s: string) {
3235
if (filterStatuses.includes(s)) {
3336
filterStatuses = filterStatuses.filter((x) => x !== s);
@@ -71,21 +74,27 @@
7174
filters.priorities = filterPriorities.join(",");
7275
if (searchQuery) filters.search = searchQuery;
7376
74-
Promise.all([
75-
listProjects(100, 0).then((r) => {
76-
projects = r.items;
77-
}),
78-
listTasks(filters).then((r) => {
79-
tasks = r.items;
80-
total = r.total;
81-
}),
82-
])
83-
.catch((e) => {
84-
error = e.message || "Failed to load tasks";
85-
})
86-
.finally(() => {
87-
loading = false;
88-
});
77+
runLatest(
78+
requestGuard,
79+
() =>
80+
Promise.all([
81+
listProjects(100, 0).then((r) => r.items),
82+
listTasks(filters).then((r) => ({ items: r.items, total: r.total })),
83+
]),
84+
{
85+
onSuccess: ([projectItems, taskRes]) => {
86+
projects = projectItems;
87+
tasks = taskRes.items;
88+
total = taskRes.total;
89+
},
90+
onError: (e) => {
91+
error = (e as Error).message || "Failed to load tasks";
92+
},
93+
onFinally: () => {
94+
loading = false;
95+
},
96+
},
97+
);
8998
}
9099
91100
function resetFilters() {

0 commit comments

Comments
 (0)