Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions packages/connect-react/src/components/SelectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,20 @@ export function SelectApp({

const instanceId = useId();

// Debounce the search query
// Debounce the search query with cleanup guard
useEffect(() => {
let cancelled = false;

const timer = setTimeout(() => {
setQ(inputValue);
if (!cancelled) {
setQ(inputValue);
}
}, 300); // 300ms delay

return () => clearTimeout(timer);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [
inputValue,
]);
Expand Down Expand Up @@ -106,7 +113,7 @@ export function SelectApp({
getOptionLabel={(o) => o.name || o.name_slug} // TODO fetch initial value app so we show name
getOptionValue={(o) => o.name_slug}
value={selectedValue}
onChange={(o) => onChange?.((o as AppResponse) || undefined)}
onChange={(o) => onChange?.(o ? (o as AppResponse) : undefined)}
onInputChange={(v, { action }) => {
// Only update on user input, not on blur/menu-close/etc
if (action === "input-change") {
Expand Down
95 changes: 87 additions & 8 deletions packages/connect-react/src/components/SelectComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useId } from "react";
import Select from "react-select";
import { useComponents } from "../hooks/use-components";
import { useId, useState, useEffect, useMemo } from "react";
import Select, { components } from "react-select";
import { useComponentsWithPagination } from "../hooks/use-components";
import {
AppResponse, V1Component,
} from "@pipedream/sdk";
Expand All @@ -19,28 +19,107 @@ export function SelectComponent({
onChange,
}: SelectComponentProps) {
const instanceId = useId();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");

// Debounce search query to avoid excessive API calls
useEffect(() => {
let cancelled = false;

const timer = setTimeout(() => {
if (!cancelled) {
setDebouncedSearchQuery(searchQuery);
}
}, 300);

return () => {
cancelled = true;
clearTimeout(timer);
};
}, [searchQuery]);

const {
isLoading, components,
} = useComponents({
isLoading,
components: allComponents,
hasMore,
loadMore,
reset,
isLoadingMore,
} = useComponentsWithPagination({
app: app?.name_slug,
componentType,
});

const selectedValue = components?.find((c) => c.key === value?.key) || null;
// Filter components based on search query (client-side)
const componentList = useMemo(() => {
if (!debouncedSearchQuery) {
return allComponents;
}
const query = debouncedSearchQuery.toLowerCase();
return allComponents.filter(component =>
component.name?.toLowerCase().includes(query) ||
component.key?.toLowerCase().includes(query)
);
}, [allComponents, debouncedSearchQuery]);

const selectedValue = useMemo(() => {
// If we have a value but it's not in the current components list,
// preserve it for display purposes
const foundComponent = componentList?.find((c) => c.key === value?.key);
if (foundComponent) {
return foundComponent;
} else if (value?.key) {
// Return the partial value to maintain selection display
return value as V1Component;
}
return null;
}, [componentList, value]);

// Custom MenuList component for infinite scroll - memoized to prevent recreation
const MenuList = useMemo(() => {
return (props: any) => (
<components.MenuList {...props}>
{props.children}
{hasMore && !isLoading && (
<div
style={{ padding: '8px', textAlign: 'center', cursor: 'pointer' }}
onClick={() => {
if (!isLoadingMore) {
loadMore();
}
}}
>
{isLoadingMore ? 'Loading more...' : 'Load more'}
</div>
)}
</components.MenuList>
);
}, [hasMore, isLoading, isLoadingMore, loadMore]);

return (
<Select
instanceId={instanceId}
className="react-select-container text-sm"
classNamePrefix="react-select"
options={components}
options={componentList}
getOptionLabel={(o) => o.name || o.key}
getOptionValue={(o) => o.key}
value={selectedValue}
onChange={(o) => onChange?.((o as V1Component) || undefined)}
onChange={(o) => onChange?.(o ? (o as V1Component) : undefined)}
isLoading={isLoading}
onInputChange={(inputValue, { action }) => {
if (action === "input-change") {
setSearchQuery(inputValue);
}
}}
noOptionsMessage={({ inputValue }) =>
isLoading ? "Loading..." :
inputValue ? `No components found for "${inputValue}"` :
"No components available"
}
components={{
IndicatorSeparator: () => null,
MenuList,
}}
/>
);
Expand Down
4 changes: 3 additions & 1 deletion packages/connect-react/src/hooks/use-apps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ export const useApps = (input?: GetAppsOpts) => {
const query = useQuery({
queryKey: [
"apps",
input,
input?.q || "", // Stable key even if input is undefined
],
queryFn: () => client.apps(input),
staleTime: 60000, // Consider data fresh for 1 minute
gcTime: 300000, // Keep in cache for 5 minutes (formerly cacheTime)
});

return {
Expand Down
4 changes: 3 additions & 1 deletion packages/connect-react/src/hooks/use-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ export const useComponent = (
const query = useQuery({
queryKey: [
"component",
key,
key || "",
],
queryFn: () => client.component({
key: key!,
}),
enabled: !!key,
staleTime: 60000, // Consider data fresh for 1 minute
gcTime: 300000, // Keep in cache for 5 minutes
...opts?.useQueryOpts,
});

Expand Down
123 changes: 121 additions & 2 deletions packages/connect-react/src/hooks/use-components.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import type { GetComponentOpts } from "@pipedream/sdk";
import { useState, useCallback, useEffect } from "react";
import type { GetComponentOpts, V1Component } from "@pipedream/sdk";
import { useFrontendClient } from "./frontend-client-context";

/**
Expand All @@ -10,13 +11,131 @@ export const useComponents = (input?: GetComponentOpts) => {
const query = useQuery({
queryKey: [
"components",
input,
input?.app || "",
input?.componentType || "",
input?.q || "",
input?.limit || 100,
input?.after || "",
],
queryFn: () => client.components(input),
staleTime: 60000, // Consider data fresh for 1 minute
gcTime: 300000, // Keep in cache for 5 minutes
});

return {
...query,
components: query.data?.data || [],
};
};

/**
* Get list of components with pagination support
*/
export const useComponentsWithPagination = (input?: Omit<GetComponentOpts, 'limit' | 'after' | 'q'>) => {
const client = useFrontendClient();
const [allComponents, setAllComponents] = useState<V1Component[]>([]);
const [hasMore, setHasMore] = useState(true);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [isLoadingMore, setIsLoadingMore] = useState(false);

// Create stable query params object
const queryParams = useCallback(() => ({
...input,
limit: 50,
after: cursor,
}), [input?.app, input?.componentType, cursor]);

const query = useQuery({
queryKey: [
"components-paginated",
input?.app || "",
input?.componentType || "",
cursor || "",
],
queryFn: () => client.components(queryParams()),
staleTime: 60000, // Consider data fresh for 1 minute
gcTime: 300000, // Keep in cache for 5 minutes
});

// Handle successful data fetch with cleanup guard
useEffect(() => {
let cancelled = false;

if (query.isSuccess && query.data && !cancelled) {
const data = query.data;

if (cursor) {
// This is a "load more" request, append to existing components
setAllComponents(prev => {
if (cancelled) return prev;
const existingKeys = new Set(prev.map(c => c.key));
const newComponents = data.data?.filter((c: V1Component) => !existingKeys.has(c.key)) || [];
return [...prev, ...newComponents];
});
} else {
// This is initial load, replace all components
if (!cancelled) {
setAllComponents(data.data || []);
}
}

// Update pagination state
if (!cancelled) {
const pageInfo = data.page_info;
setHasMore(pageInfo ? (pageInfo.count >= 50) : false);
setIsLoadingMore(false);
}
Comment on lines +82 to +87
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

hasMore heuristic may misfire

page_info.count >= 50 assumes the API always returns exactly limit items until exhaustion. Prefer a flag from the API (has_next, next_cursor) or compare data.length < limit to decide.

🤖 Prompt for AI Agents
In packages/connect-react/src/hooks/use-components.tsx around lines 82 to 87,
the current logic sets hasMore based on page_info.count >= 50, which assumes the
API always returns the full limit of items until no more are available. To fix
this, update the logic to use a more reliable indicator from the API such as a
has_next flag or next_cursor if available, or alternatively compare the length
of the returned data array to the requested limit to determine if more items
exist. Adjust setHasMore accordingly to reflect this improved heuristic.

}

if (query.isError && !cancelled) {
setIsLoadingMore(false);
}

return () => {
cancelled = true;
};
}, [query.isSuccess, query.isError, query.data, cursor]);

// Load more function - don't depend on entire query object
const loadMore = useCallback(() => {
if (hasMore && !query.isFetching && !isLoadingMore && query.data?.page_info?.end_cursor) {
setIsLoadingMore(true);
setCursor(query.data.page_info.end_cursor);
}
}, [hasMore, query.isFetching, query.data?.page_info?.end_cursor, isLoadingMore]);

const reset = useCallback(() => {
setAllComponents([]);
setHasMore(true);
setCursor(undefined);
setIsLoadingMore(false);
}, []);

// Reset when input changes (e.g., different app or componentType)
useEffect(() => {
let cancelled = false;

// Use a microtask to avoid state updates during render
queueMicrotask(() => {
if (!cancelled) {
setAllComponents([]);
setHasMore(true);
setCursor(undefined);
setIsLoadingMore(false);
}
});

return () => {
cancelled = true;
};
}, [input?.app, input?.componentType]);

return {
...query,
components: allComponents,
hasMore,
loadMore,
reset,
isLoadingMore,
};
};
20 changes: 8 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading