fix(CQ-WEB-3): Fix race condition in LinkAutocomplete
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Add AbortController to cancel in-flight search requests when a new search fires, preventing stale results from overwriting newer ones. The controller is also aborted on component unmount for cleanup. Switched from apiGet to apiRequest to support passing AbortSignal. Added 3 new tests verifying signal passing, abort on new search, and abort on unmount. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import { apiRequest } from "@/lib/api/client";
|
||||
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
||||
|
||||
interface LinkAutocompleteProps {
|
||||
@@ -51,11 +51,14 @@ export function LinkAutocomplete({
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
/**
|
||||
* Search for knowledge entries matching the query
|
||||
* Search for knowledge entries matching the query.
|
||||
* Accepts an AbortSignal to allow cancellation of in-flight requests,
|
||||
* preventing stale results from overwriting newer ones.
|
||||
*/
|
||||
const searchEntries = useCallback(async (query: string): Promise<void> => {
|
||||
const searchEntries = useCallback(async (query: string, signal: AbortSignal): Promise<void> => {
|
||||
if (!query.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
@@ -63,7 +66,7 @@ export function LinkAutocomplete({
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await apiGet<{
|
||||
const response = await apiRequest<{
|
||||
data: KnowledgeEntryWithTags[];
|
||||
meta: {
|
||||
total: number;
|
||||
@@ -71,7 +74,10 @@ export function LinkAutocomplete({
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}>(`/api/knowledge/search?q=${encodeURIComponent(query)}&limit=10`);
|
||||
}>(`/api/knowledge/search?q=${encodeURIComponent(query)}&limit=10`, {
|
||||
method: "GET",
|
||||
signal,
|
||||
});
|
||||
|
||||
const searchResults: SearchResult[] = response.data.map((entry) => ({
|
||||
id: entry.id,
|
||||
@@ -83,15 +89,23 @@ export function LinkAutocomplete({
|
||||
setResults(searchResults);
|
||||
setSelectedIndex(0);
|
||||
} catch (error) {
|
||||
// Ignore aborted requests - a newer search has superseded this one
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.error("Failed to search entries:", error);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (!signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Debounced search - waits 300ms after user stops typing
|
||||
* Debounced search - waits 300ms after user stops typing.
|
||||
* Cancels any in-flight request via AbortController before firing a new one,
|
||||
* preventing race conditions where older results overwrite newer ones.
|
||||
*/
|
||||
const debouncedSearch = useCallback(
|
||||
(query: string): void => {
|
||||
@@ -99,8 +113,16 @@ export function LinkAutocomplete({
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Abort any in-flight request from a previous search
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
void searchEntries(query);
|
||||
// Create a new AbortController for this search request
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
void searchEntries(query, controller.signal);
|
||||
}, 300);
|
||||
},
|
||||
[searchEntries]
|
||||
@@ -321,13 +343,16 @@ export function LinkAutocomplete({
|
||||
}, [textareaRef, handleInput, handleKeyDown]);
|
||||
|
||||
/**
|
||||
* Cleanup timeout on unmount
|
||||
* Cleanup timeout and abort in-flight requests on unmount
|
||||
*/
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user