fix: address PR review feedback (#1467)
- guard optional project selection callbacks and keep bookmarked search matches visible - use structured timeout/rate-limit handling in retry backoff - add focused regressions for selector and retry behavior Note: pnpm test passed the merge gate but hit pre-existing non-blocking engine mock failures in changed-package tests.
This commit is contained in:
3
.changeset/fn-1467-pr-feedback-fixes.md
Normal file
3
.changeset/fn-1467-pr-feedback-fixes.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"@runfusion/fusion": patch
|
||||||
|
|
||||||
|
Fix project selector review regressions around optional selection handlers and bookmarked search matches, and tighten retry/backoff timeout and rate-limit handling.
|
||||||
@@ -836,7 +836,7 @@ export function Header({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */}
|
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */}
|
||||||
{!isMobile && projects.length >= 1 && onViewAllProjects && (
|
{!isMobile && projects.length >= 1 && onViewAllProjects && onSelectProject && (
|
||||||
<StandaloneProjectSelector
|
<StandaloneProjectSelector
|
||||||
projects={projects}
|
projects={projects}
|
||||||
currentProject={currentProject ?? null}
|
currentProject={currentProject ?? null}
|
||||||
|
|||||||
@@ -169,27 +169,31 @@ export function ProjectSelector({
|
|||||||
const displayProjects = useMemo(() => {
|
const displayProjects = useMemo(() => {
|
||||||
const recentIds = new Set(recentProjects.map((p) => p.id));
|
const recentIds = new Set(recentProjects.map((p) => p.id));
|
||||||
const currentId = currentProject?.id;
|
const currentId = currentProject?.id;
|
||||||
|
const hasSearch = Boolean(searchQuery.trim());
|
||||||
|
|
||||||
// Bookmarked projects (excluding current)
|
// Bookmarked projects (excluding current)
|
||||||
const bookmarked = filteredProjects.filter(
|
const bookmarked = hasSearch
|
||||||
(p) =>
|
? []
|
||||||
p.id !== currentId &&
|
: filteredProjects.filter(
|
||||||
bookmarkedIds.has(p.id) &&
|
(p) =>
|
||||||
!recentIds.has(p.id)
|
p.id !== currentId &&
|
||||||
);
|
bookmarkedIds.has(p.id) &&
|
||||||
|
!recentIds.has(p.id)
|
||||||
|
);
|
||||||
|
|
||||||
// Exclude current, bookmarked, and recent from "others"
|
// Exclude current, bookmarked, and recent from "others" only when those
|
||||||
|
// sections are visible. Search mode surfaces every matching project here.
|
||||||
const bookmarkedAndRecentIds = new Set([
|
const bookmarkedAndRecentIds = new Set([
|
||||||
...bookmarked.map((p) => p.id),
|
...bookmarked.map((p) => p.id),
|
||||||
...recentIds,
|
...(hasSearch ? [] : recentIds),
|
||||||
]);
|
]);
|
||||||
const others = filteredProjects.filter(
|
const others = filteredProjects.filter(
|
||||||
(p) => p.id !== currentId && !bookmarkedAndRecentIds.has(p.id)
|
(p) => p.id !== currentId && !bookmarkedAndRecentIds.has(p.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookmarked: searchQuery.trim() ? [] : bookmarked,
|
bookmarked,
|
||||||
recent: searchQuery.trim() ? [] : recentProjects,
|
recent: hasSearch ? [] : recentProjects,
|
||||||
others,
|
others,
|
||||||
};
|
};
|
||||||
}, [filteredProjects, recentProjects, currentProject, searchQuery, bookmarkedIds]);
|
}, [filteredProjects, recentProjects, currentProject, searchQuery, bookmarkedIds]);
|
||||||
@@ -228,13 +232,13 @@ export function ProjectSelector({
|
|||||||
|
|
||||||
if (highlightedIndex < bookmarkedCount) {
|
if (highlightedIndex < bookmarkedCount) {
|
||||||
// Select bookmarked project
|
// Select bookmarked project
|
||||||
onSelect(displayProjects.bookmarked[highlightedIndex]);
|
onSelect?.(displayProjects.bookmarked[highlightedIndex]);
|
||||||
} else if (highlightedIndex < bookmarkedCount + recentCount) {
|
} else if (highlightedIndex < bookmarkedCount + recentCount) {
|
||||||
// Select recent project
|
// Select recent project
|
||||||
onSelect(displayProjects.recent[highlightedIndex - bookmarkedCount]);
|
onSelect?.(displayProjects.recent[highlightedIndex - bookmarkedCount]);
|
||||||
} else if (highlightedIndex < bookmarkedCount + recentCount + othersCount) {
|
} else if (highlightedIndex < bookmarkedCount + recentCount + othersCount) {
|
||||||
// Select other project
|
// Select other project
|
||||||
onSelect(displayProjects.others[highlightedIndex - bookmarkedCount - recentCount]);
|
onSelect?.(displayProjects.others[highlightedIndex - bookmarkedCount - recentCount]);
|
||||||
} else {
|
} else {
|
||||||
// View All
|
// View All
|
||||||
onViewAll();
|
onViewAll();
|
||||||
@@ -297,7 +301,7 @@ export function ProjectSelector({
|
|||||||
// Handle project selection
|
// Handle project selection
|
||||||
const handleSelectProject = useCallback(
|
const handleSelectProject = useCallback(
|
||||||
(project: ProjectInfo) => {
|
(project: ProjectInfo) => {
|
||||||
onSelect(project);
|
onSelect?.(project);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -193,6 +193,23 @@ describe("ProjectSelector", () => {
|
|||||||
expect(onSelect).toHaveBeenCalledWith(projectTwo);
|
expect(onSelect).toHaveBeenCalledWith(projectTwo);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not throw when clicking a project without onSelect", () => {
|
||||||
|
render(
|
||||||
|
<ProjectSelector
|
||||||
|
projects={[
|
||||||
|
makeProject({ id: "proj_1", name: "Project One" }),
|
||||||
|
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||||
|
]}
|
||||||
|
currentProject={makeProject({ id: "proj_1" })}
|
||||||
|
onViewAll={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||||
|
expect(() => fireEvent.click(screen.getByText("Project Two"))).not.toThrow();
|
||||||
|
expect(screen.queryByTestId("project-selector-dropdown")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("closes dropdown after selection", () => {
|
it("closes dropdown after selection", () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
@@ -731,6 +748,53 @@ describe("ProjectSelector", () => {
|
|||||||
expect(screen.getByTestId("project-selector-no-results")).toBeDefined();
|
expect(screen.getByTestId("project-selector-no-results")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows bookmarked projects that match an active search", () => {
|
||||||
|
mockBookmarkedIds = new Set(["proj_2"]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProjectSelector
|
||||||
|
projects={[
|
||||||
|
makeProject({ id: "proj_1", name: "Alpha" }),
|
||||||
|
makeProject({ id: "proj_2", name: "Starred Project" }),
|
||||||
|
]}
|
||||||
|
currentProject={makeProject({ id: "proj_1" })}
|
||||||
|
onSelect={noop}
|
||||||
|
onViewAll={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Search projects..."), {
|
||||||
|
target: { value: "Starred" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Starred")).toBeDefined();
|
||||||
|
expect(screen.getByText("Project")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("project-selector-no-results")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw when pressing Enter on a highlighted project without onSelect", async () => {
|
||||||
|
render(
|
||||||
|
<ProjectSelector
|
||||||
|
projects={[
|
||||||
|
makeProject({ id: "proj_1", name: "Alpha" }),
|
||||||
|
makeProject({ id: "proj_2", name: "Beta" }),
|
||||||
|
]}
|
||||||
|
currentProject={makeProject({ id: "proj_1" })}
|
||||||
|
onViewAll={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search projects...");
|
||||||
|
fireEvent.change(searchInput, { target: { value: "Beta" } });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("project-selector-item-proj_2").className).toContain("highlighted");
|
||||||
|
});
|
||||||
|
expect(() => fireEvent.keyDown(searchInput, { key: "Enter" })).not.toThrow();
|
||||||
|
expect(screen.queryByTestId("project-selector-dropdown")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("clear button clears search query", () => {
|
it("clear button clears search query", () => {
|
||||||
render(
|
render(
|
||||||
<ProjectSelector
|
<ProjectSelector
|
||||||
|
|||||||
@@ -327,6 +327,22 @@ describe("withRetry", () => {
|
|||||||
expect(onRetry).not.toHaveBeenCalled();
|
expect(onRetry).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let a custom retry check override RateLimitError instances", async () => {
|
||||||
|
const fn = vi.fn().mockRejectedValue(new RateLimitError("429"));
|
||||||
|
const onRetry = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withRetry(fn, {
|
||||||
|
baseDelayMs: 100,
|
||||||
|
onRetry,
|
||||||
|
isRetryable: () => true,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("429");
|
||||||
|
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRetry).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("applies exponential backoff with increasing delays", async () => {
|
it("applies exponential backoff with increasing delays", async () => {
|
||||||
const fn = vi
|
const fn = vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -586,6 +602,27 @@ describe("withRetry", () => {
|
|||||||
const retryErr = onRetry.mock.calls[0][2];
|
const retryErr = onRetry.mock.calls[0][2];
|
||||||
expect(retryErr.code).toBe("TIMEOUT");
|
expect(retryErr.code).toBe("TIMEOUT");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes a per-attempt abort signal that is aborted on timeout", async () => {
|
||||||
|
let attemptSignal: AbortSignal | undefined;
|
||||||
|
const fn = vi.fn((signal?: AbortSignal) => {
|
||||||
|
attemptSignal = signal;
|
||||||
|
return new Promise(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = withRetry(fn, {
|
||||||
|
maxRetries: 0,
|
||||||
|
timeoutMs: 500,
|
||||||
|
});
|
||||||
|
const assertion = expect(promise).rejects.toBeInstanceOf(TimeoutError);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
await assertion;
|
||||||
|
expect(attemptSignal).toBeInstanceOf(AbortSignal);
|
||||||
|
expect(attemptSignal?.aborted).toBe(true);
|
||||||
|
expect(attemptSignal?.reason).toBeInstanceOf(TimeoutError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("withRetryResult", () => {
|
describe("withRetryResult", () => {
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export function classifyThrownError(err: unknown): EngineError {
|
|||||||
return new ServiceUnavailableError(message, undefined, undefined, err instanceof Error ? err : undefined);
|
return new ServiceUnavailableError(message, undefined, undefined, err instanceof Error ? err : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/"type":"server_error"|\"code\":\"server_error\"/i.test(message)) {
|
if (/"type":"server_error"|"code":"server_error"/i.test(message)) {
|
||||||
return new ServiceUnavailableError(message, 500, undefined, err instanceof Error ? err : undefined);
|
return new ServiceUnavailableError(message, 500, undefined, err instanceof Error ? err : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,13 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { classifyThrownError, isRetryableError, type EngineError } from "./engine-errors.js";
|
import {
|
||||||
|
classifyThrownError,
|
||||||
|
isRetryableError,
|
||||||
|
RateLimitError,
|
||||||
|
TimeoutError,
|
||||||
|
type EngineError,
|
||||||
|
} from "./engine-errors.js";
|
||||||
import { isUsageLimitError } from "./usage-limit-detector.js";
|
import { isUsageLimitError } from "./usage-limit-detector.js";
|
||||||
|
|
||||||
// ── Types ───────────────────────────────────────────────────────────────
|
// ── Types ───────────────────────────────────────────────────────────────
|
||||||
@@ -168,7 +174,7 @@ export function cancellableSleep(ms: number, signal?: AbortSignal): Promise<void
|
|||||||
* the AbortController.
|
* the AbortController.
|
||||||
*/
|
*/
|
||||||
function withTimeout<T>(
|
function withTimeout<T>(
|
||||||
fn: () => Promise<T>,
|
fn: (signal?: AbortSignal) => Promise<T>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
parentSignal?: AbortSignal,
|
parentSignal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
@@ -188,11 +194,12 @@ function withTimeout<T>(
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
ac.abort(new Error(`Operation timed out after ${timeoutMs}ms`));
|
const timeoutErr = new TimeoutError(`Operation timed out after ${timeoutMs}ms`, timeoutMs);
|
||||||
reject(new Error(`Operation timed out after ${timeoutMs}ms`));
|
ac.abort(timeoutErr);
|
||||||
|
reject(timeoutErr);
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
fn()
|
fn(ac.signal)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
@@ -229,7 +236,7 @@ function withTimeout<T>(
|
|||||||
* @returns The return value of `fn()`
|
* @returns The return value of `fn()`
|
||||||
*/
|
*/
|
||||||
export async function withRetry<T>(
|
export async function withRetry<T>(
|
||||||
fn: () => Promise<T>,
|
fn: (signal?: AbortSignal) => Promise<T>,
|
||||||
options: RetryOptions = {},
|
options: RetryOptions = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const {
|
const {
|
||||||
@@ -243,9 +250,7 @@ export async function withRetry<T>(
|
|||||||
isRetryable: customIsRetryable,
|
isRetryable: customIsRetryable,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
let lastError: EngineError | undefined;
|
let lastError: EngineError | undefined;
|
||||||
let retryCount = 0;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
// Check abort before each attempt
|
// Check abort before each attempt
|
||||||
@@ -257,14 +262,14 @@ export async function withRetry<T>(
|
|||||||
// Wrap with timeout if configured
|
// Wrap with timeout if configured
|
||||||
const result = timeoutMs
|
const result = timeoutMs
|
||||||
? await withTimeout(fn, timeoutMs, signal)
|
? await withTimeout(fn, timeoutMs, signal)
|
||||||
: await fn();
|
: await fn(signal);
|
||||||
return result;
|
return result;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Classify the error into a structured type
|
// Classify the error into a structured type
|
||||||
const classified = classifyThrownError(err);
|
const classified = classifyThrownError(err);
|
||||||
|
|
||||||
// Rate-limit errors: never retry locally — re-throw immediately
|
// Rate-limit errors: never retry locally — re-throw immediately
|
||||||
if (isUsageLimitError(classified.message)) {
|
if (classified instanceof RateLimitError || isUsageLimitError(classified.message)) {
|
||||||
throw classified;
|
throw classified;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,8 +302,6 @@ export async function withRetry<T>(
|
|||||||
|
|
||||||
// Sleep with cancellation support
|
// Sleep with cancellation support
|
||||||
await cancellableSleep(delay, signal);
|
await cancellableSleep(delay, signal);
|
||||||
|
|
||||||
retryCount++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,17 +320,14 @@ export async function withRetry<T>(
|
|||||||
* @returns A `RetryResult<T>` with value and retry metadata
|
* @returns A `RetryResult<T>` with value and retry metadata
|
||||||
*/
|
*/
|
||||||
export async function withRetryResult<T>(
|
export async function withRetryResult<T>(
|
||||||
fn: () => Promise<T>,
|
fn: (signal?: AbortSignal) => Promise<T>,
|
||||||
options: RetryOptions = {},
|
options: RetryOptions = {},
|
||||||
): Promise<RetryResult<T>> {
|
): Promise<RetryResult<T>> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let retries = 0;
|
let retries = 0;
|
||||||
|
|
||||||
const result = await withRetry<T>(async () => {
|
const result = await withRetry<T>(async (signal) => {
|
||||||
if (retries > 0) {
|
return fn(signal);
|
||||||
// We're in a retry — count it
|
|
||||||
}
|
|
||||||
return fn();
|
|
||||||
}, {
|
}, {
|
||||||
...options,
|
...options,
|
||||||
onRetry: (attempt, delayMs, err) => {
|
onRetry: (attempt, delayMs, err) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user