.claude/skills/relay-infinite-scroll-select/SKILL.md
Create Relay-based infinite scroll select components extending BAISelect. Supports name-based values (usePaginationFragment) and id-based values (useLazyLoadQuery + useLazyPaginatedQuery) with search, optimistic updates, and multiple selection modes.
npx skillsauth add lablup/backend.ai-webui relay-infinite-scroll-selectInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
START: Does your select need dynamic query parameters?
┌─────────────────────────────────────────────────────────────┐
│ Q: Do you need dynamic control over query parameters? │
│ (filter, limit, first, order, etc.) │
│ OR need external refetch capability? │
└─────────────────────────────────────────────────────────────┘
│
┌──────────────┴──────────────┐
│ │
NO YES
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Pattern A │ │ Pattern B │
│ (Simple) │ │ (Dynamic) │
└─────────────┘ └─────────────┘
│ │
▼ ▼
Reference: References:
BAIAdminResourceGroupSelect BAIUserSelect (email-based)
BAIVFolderSelect (id-based)
| Criteria | Pattern A (Simple) | Pattern B (Dynamic) | |----------|-------------------|-------------------| | Value Type | String (name) | Any (name, email, id, row_id, etc.) | | Relay Hook | usePaginationFragment | useLazyLoadQuery + useLazyPaginatedQuery | | Queries | 1 fragment | 2 queries | | Dynamic First | ❌ Not needed | ✅ Default (fetches all selected values) | | Dynamic Parameters | ❌ Limited | ✅ Full control (filter, first, limit, order, etc.) | | Multiple Mode | Single only | Full support | | Global ID | Not needed | Can handle (if needed) | | Optimistic UI | Not needed | Required | | State Management | Simple | Complex | | Ref Export | No | Yes (refetch support) | | Complexity | 🟢 ~100 lines | 🟡 ~300-350 lines | | Use Case | Simple, static requirements | Dynamic filters, external refetch, multiple props control |
| Example | Value Type | Special Features |
|---------|-----------|------------------|
| BAIUserSelect | Email | Dynamic first, email filtering |
| BAIVFolderSelect | ID / row_id | Dynamic first, Global ID conversion, scope filtering |
| Custom Select | Name | Can use Pattern B even with name if dynamic control needed |
Component Setup:
usePaginationFragment from 'react-relay'Omit<BAISelectProps, 'options' | 'labelInValue'>queryRef prop for fragment keyGraphQL Fragment:
graphql\`
fragment YourComponent_fragment on Query
@argumentDefinitions(
first: { type: "Int", defaultValue: 10 }
after: { type: "String" }
filter: { type: "YourFilterType" }
)
@refetchable(queryName: "YourComponentPaginationQuery") {
yourEntities(first: $first, after: $after, filter: $filter)
@connection(key: "YourComponent_yourEntities") {
count
edges {
node {
id
name
}
}
}
}
\`
Required Checklist:
BAISelect Integration:
ref={selectRef} with useRefoptions from mapped edgessearchAction with refetch() and scrollTo(0)showSearch with conditional handling (see showSearch Configuration section)endReached={() => hasNext && loadNext(10)}notFoundContent with Skeletonfooter with TotalFooterComponent Setup:
useLazyLoadQuery, useDeferredValue, useTransition, useOptimisticuseLazyPaginatedQuery, useFetchKey, useDebouncedDeferredValue custom hooksuseControllableValue from 'ahooks'toLocalId, mergeFilterValues helpersState Management:
useControllableValue for value and openuseDeferredValue for controllableValue, open, and fetchKeyuseState for searchStr and optimisticValueWithLabeluseDebouncedDeferredValue for searchStr (debounce + defer query execution)useOptimistic for optimisticSearchStr (immediate UI feedback) - simple pattern without dispatcheruseTransition for refetchuseFetchKey for cache invalidationGraphQL Queries:
Query 1 - Selected Values (with Dynamic First):
graphql\`
query YourComponentValueQuery(
$selectedFilter: String
$first: Int!
$skipSelected: Boolean!
) {
yourEntities(filter: $selectedFilter, first: $first)
@skip(if: $skipSelected) {
edges {
node {
id
row_id
name
}
}
}
}
\`
// Variables
{
selectedFilter: /* filter based on selected values */,
first: _.castArray(deferredControllableValue).length, // 🔑 Dynamic
skipSelected: _.isEmpty(deferredControllableValue),
}
Query 2 - Paginated Options:
graphql\`
query YourComponentPaginatedQuery(
$offset: Int!
$limit: Int!
$filter: String
) {
yourEntities(
offset: $offset
first: $limit
filter: $filter
order: "-created_at"
) {
count
edges {
node {
id
row_id
name
}
}
}
}
\`
Query Checklist:
$first parameter (REQUIRED for Pattern B)first: _.castArray(value).length (REQUIRED for Pattern B)Value-to-Label Mapping:
controllableValueWithLabel from selected queryOptimistic Updates:
useOptimistic for optimisticSearchStr (search input feedback)useState for optimisticValueWithLabel (selection feedback)BAISelect Integration:
labelInValue propvalue with optimistic switchingonChange with label preservationsearchAction with setOptimisticSearchStr then setSearchStr (no transition wrapper needed)showSearch with conditional handling (see showSearch Configuration section)showSearch.searchValue set to optimisticSearchStr for immediate feedbackendReached={() => loadNext()}labelRender/optionRender for custom displayloading with four conditions: loading, value comparison, searchStr !== debouncedDeferredValue, isPendingRefetchRef Export:
useImperativeHandle for refetchstartRefetchTransition wrapperBoth patterns should implement flexible showSearch handling to support:
showSearch={false}showSearch configurationsPattern A: Basic showSearch
// State (if not already present)
const [searchStr, setSearchStr] = useState<string>();
const [optimisticSearchStr, setOptimisticSearchStr] =
useOptimistic(searchStr);
// In searchAction
searchAction={async (value) => {
setOptimisticSearchStr(value); // Immediate UI feedback
setSearchStr(value); // Actual state for query
selectRef.current?.scrollTo(0);
refetch({ filter: value ? { name: { contains: value } } : null });
await selectProps.searchAction?.(value);
}}
// In BAISelect
showSearch={
selectProps.showSearch === false
? false
: {
searchValue: optimisticSearchStr,
autoClearSearchValue: true,
filterOption: false,
...(_.isObject(selectProps.showSearch)
? _.omit(selectProps.showSearch, ['searchValue'])
: {}),
}
}
Pattern B: Advanced showSearch with debouncing
// State (already present in Pattern B)
const [searchStr, setSearchStr] = useState<string>();
const debouncedDeferredValue = useDebouncedDeferredValue(searchStr);
const [optimisticSearchStr, setOptimisticSearchStr] =
useOptimistic(searchStr);
// In searchAction
searchAction={async (value) => {
setOptimisticSearchStr(value); // Immediate UI feedback
setSearchStr(value); // Actual state (will be debounced + deferred)
await selectProps.searchAction?.(value);
}}
// In BAISelect
showSearch={
selectProps.showSearch === false
? false
: {
searchValue: optimisticSearchStr,
autoClearSearchValue: true,
filterOption: false,
...(_.isObject(selectProps.showSearch)
? _.omit(selectProps.showSearch, ['searchValue'])
: {}),
}
}
Why this pattern?
showSearch={false}showSearch config (except searchValue)optimisticSearchStr for immediate feedbackUsage Examples:
// Disable search completely
<BAIUserSelect showSearch={false} />
// Custom showSearch configuration
<BAIUserSelect
showSearch={{
placeholder: "Search by email...",
maxLength: 50,
}}
/>
// Default behavior (search enabled)
<BAIUserSelect />
Common Pitfalls:
// ❌ Bad: Hardcoded showSearch object (not flexible)
showSearch={{
autoClearSearchValue: true,
filterOption: false,
}}
// ❌ Bad: No optimistic search (delayed feedback)
showSearch={{
searchValue: searchStr, // Use optimisticSearchStr instead
autoClearSearchValue: true,
filterOption: false,
}}
// ❌ Bad: Not checking showSearch === false
showSearch={{
// Always shows search, can't be disabled
}}
// ✅ Good: Conditional with optimistic + merge
showSearch={
selectProps.showSearch === false
? false
: {
searchValue: optimisticSearchStr,
autoClearSearchValue: true,
filterOption: false,
...(_.isObject(selectProps.showSearch)
? _.omit(selectProps.showSearch, ['searchValue'])
: {}),
}
}
Pattern B always uses dynamic first parameter to ensure all selected values are fetched:
const { entity_nodes: selectedNodes } =
useLazyLoadQuery<YourComponentValueQuery>(
graphql`
query YourComponentValueQuery(
$selectedFilter: String
$first: Int!
$skipSelected: Boolean!
) {
entity_nodes(filter: $selectedFilter, first: $first)
@skip(if: $skipSelected) {
edges {
node {
id
name
}
}
}
}
`,
{
selectedFilter: /* ... */,
first: _.castArray(deferredControllableValue).length, // 🔑 Dynamic
skipSelected: _.isEmpty(deferredControllableValue),
},
);
Why this is the default in Pattern B:
Without dynamic first (NOT Pattern B):
// ❌ Bad: Hardcoded limit (Pattern A approach, not suitable for Pattern B)
first: 10 // Fails if user selects 50 items
// ❌ Bad: No first parameter
// May return only default number of results (usually 10)
// ✅ Good: Pattern B always uses dynamic first
first: _.castArray(deferredControllableValue).length
// Check for empty value before casting to avoid issues with empty arrays
const valueArray = _.isEmpty(value) ? [] : _.castArray(value);
// Process each value uniformly
valueArray.map((value) => {
// Process each value
});
Key Points:
_.isEmpty() to check for empty value before casting_.castArray() ensures uniform handling of single and multiple modes// Pattern A: Refetch with transition and optimistic search
const [searchStr, setSearchStr] = useState<string>();
const [optimisticSearchStr, setOptimisticSearchStr] =
useOptimistic(searchStr);
searchAction={async (value) => {
setOptimisticSearchStr(value); // Immediate UI feedback
setSearchStr(value); // Actual state for query
selectRef.current?.scrollTo(0);
refetch({ filter: value ? { name: { contains: value } } : null });
await selectProps.searchAction?.(value);
}}
// Conditional showSearch with user config merge
showSearch={
selectProps.showSearch === false
? false
: {
searchValue: optimisticSearchStr,
autoClearSearchValue: true,
filterOption: false,
...(_.isObject(selectProps.showSearch)
? _.omit(selectProps.showSearch, ['searchValue'])
: {}),
}
}
// Pattern B: Optimistic search with useDebouncedDeferredValue
import useDebouncedDeferredValue from '../../helper/useDebouncedDeferredValue';
const [searchStr, setSearchStr] = useState<string>();
const debouncedDeferredValue = useDebouncedDeferredValue(searchStr);
const [optimisticSearchStr, setOptimisticSearchStr] =
useOptimistic(searchStr);
// Use debouncedDeferredValue (debounced + deferred) in query filter
filter: mergeFilterValues([
mergedFilter,
debouncedDeferredValue ? `email ilike "%${debouncedDeferredValue}%"` : null,
])
// searchAction sets optimistic value immediately, then actual value
searchAction={async (value) => {
setOptimisticSearchStr(value); // Optimistic update for immediate UI feedback
setSearchStr(value); // Actual state (will be debounced + deferred)
await selectProps.searchAction?.(value);
}}
// Conditional showSearch with user config merge
showSearch={
selectProps.showSearch === false
? false
: {
searchValue: optimisticSearchStr,
autoClearSearchValue: true,
filterOption: false,
...(_.isObject(selectProps.showSearch)
? _.omit(selectProps.showSearch, ['searchValue'])
: {}),
}
}
// Show loading when search query is executing
loading={
loading ||
controllableValue !== deferredControllableValue ||
searchStr !== debouncedDeferredValue || // Loading during debounce + defer
isPendingRefetch
}
Why useDebouncedDeferredValue + useOptimistic for search?
useDeferredValue prevents UI blocking during query executiondebouncedDeferredValue (debounced + deferred state)import { toLocalId, toGlobalId } from '../../helper';
// When filtering (valuePropName === 'id')
const filterValue = valuePropName === 'id' ? toLocalId(value) : value;
import { mergeFilterValues } from '../BAIPropertyFilter';
const filter = mergeFilterValues([
baseFilter,
searchStr ? \`name ilike "%\${searchStr}%"\` : null,
externalFilter,
], '&'); // Default operator
const [value, setValue] = useControllableValue(props);
const [open, setOpen] = useControllableValue(props, {
valuePropName: 'open',
trigger: 'onOpenChange',
});
Pattern B (Dynamic) provides full control over GraphQL query parameters through props:
1. Dynamic First Parameter (Default Behavior)
// ALWAYS fetch all selected values in Pattern B
first: _.castArray(deferredControllableValue).length
This is not optional - it's the defining characteristic of Pattern B that ensures data completeness.
2. Dynamic Filter
// Combine multiple filters dynamically
filter={mergeFilterValues([
'status == "ACTIVE"',
searchStr ? `name ilike "%${searchStr}%"` : null,
props.filter, // External filter from props
])}
3. Dynamic Limit
// Control pagination size via props
{ limit: props.pageSize || 10 }
4. Dynamic Order
// Control sort order via props
order: props.sortBy || '-created_at'
5. External Refetch
// Expose refetch via ref
const selectRef = useRef<YourSelectRef>(null);
selectRef.current?.refetch();
Even if your value is a simple name (not ID), use Pattern B when you need:
Example: Name-based but needs Pattern B
// Even though value is 'name', we need Pattern B for dynamic features
<YourEntitySelect
value={selectedNames} // Name-based value
filter={externalFilter} // 🔑 Dynamic filter
pageSize={20} // 🔑 Dynamic limit
sortBy="name" // 🔑 Dynamic order
ref={selectRef} // 🔑 Refetch capability
/>
Use 'use memo' directive (Pattern B)
const YourSelect: React.FC<Props> = (props) => {
'use memo';
// Component logic
};
Defer values to prevent Suspense flicker
const deferredOpen = useDeferredValue(open);
const deferredValue = useDeferredValue(value);
const deferredFetchKey = useDeferredValue(fetchKey);
Search optimization with useDebouncedDeferredValue (Recommended):
import useDebouncedDeferredValue from '../../helper/useDebouncedDeferredValue';
const [searchStr, setSearchStr] = useState<string>();
const debouncedDeferredValue = useDebouncedDeferredValue(searchStr, {
wait: 200, // default
});
const [optimisticSearchStr, setOptimisticSearchStr] =
useOptimistic(searchStr);
Why useDebouncedDeferredValue?
useDebounce + useDeferredValue for optimal search performanceoptimisticSearchStr for immediate input feedbackdebouncedDeferredValue in query filtersPattern comparison:
// ❌ Old: Only useDeferredValue (too many queries)
const deferredSearchStr = useDeferredValue(searchStr);
// ❌ Old: Only useDebounce (can still block UI)
const debouncedSearchStr = useDebounce(searchStr);
// ✅ New: Combined approach (optimal)
const debouncedDeferredValue = useDebouncedDeferredValue(searchStr);
Optimize fetchPolicy
// Selected values
fetchPolicy: !_.isEmpty(value) ? 'store-or-network' : 'store-only'
// Paginated options
fetchPolicy: deferredOpen ? 'network-only' : 'store-only'
Skip unnecessary queries
@skip(if: $skipSelected)
Always scroll to top on search
selectRef.current?.scrollTo(0);
Maintain selection order
_.castArray(deferredValue)
.map((v) => findEdge(v))
.filter(Boolean);
Handle React element labels
const label = _.isString(v.label)
? v.label
: (options.find((opt) => opt.value === v.value)?.label ?? v.value);
Custom label rendering for IDs
import { toLocalId } from '../../helper';
import BAIText from '../BAIText';
labelRender={({ label }) => {
return valuePropName === 'id' && _.isString(label) ? (
<BAIText monospace>{toLocalId(label)}</BAIText>
) : (
label
);
}}
optionRender={({ label }) => {
return valuePropName === 'id' && _.isString(label) ? (
<BAIText monospace>{toLocalId(label)}</BAIText>
) : (
label
);
}}
Benefits:
valuePropNameLoading state priorities
loading={
loading ||
controllableValue !== deferredControllableValue ||
searchStr !== debouncedDeferredValue || // Show loading during debounce + defer
isPendingRefetch
}
// Note: searchStr comparison is needed to show loading during debounce + defer period
// useOptimistic handles immediate input feedback
| Pitfall | Impact | Solution |
|---------|--------|----------|
| Missing first parameter | Incomplete selected values | REQUIRED in Pattern B: Add $first: Int! parameter |
| Hardcoded first: 10 | Missing data for >10 selections | Pattern B always uses: _.castArray(value).length |
| Not checking _.isEmpty() before _.castArray() | Potential issues with empty values | Use _.isEmpty(value) ? [] : _.castArray(value) |
| Missing _.castArray | Single mode breaks | Always normalize values |
| Not using deferredValue | Suspense flicker | Defer controllable values (value, open, fetchKey) |
| Not using useOptimistic for search | Poor search UX | Use useOptimistic for immediate feedback |
| Not using useDebouncedDeferredValue | Too many queries | Use useDebouncedDeferredValue for search |
| Missing loading condition | No loading feedback | Add searchStr !== debouncedDeferredValue |
| Missing @skip directive | Unnecessary queries | Add skip when empty |
| Not preserving labels | Lost labels on tag removal | Check if label is string or element |
| Hardcoded valuePropName | Inflexible component | Use prop: 'id' \| 'row_id' |
| Direct option mutation | Stale data | Rebuild from query results |
| No scroll on search | Poor UX | Call selectRef.current?.scrollTo(0) |
| Wrong fetchPolicy | Performance issues | Use appropriate policy per query |
| Hardcoded showSearch object | Can't disable search | Use conditional pattern with showSearch === false check |
| Not using optimisticSearchStr | Delayed search feedback | Use useOptimistic for searchValue |
| Not merging user showSearch | Inflexible configuration | Merge with _.omit(selectProps.showSearch, ['searchValue']) |
export interface YourComponentSelectProps
extends Omit<BAISelectProps, 'options' | 'labelInValue'> {
// Pattern A
queryRef?: YourFragment$key;
// Pattern B
valuePropName?: 'id' | 'row_id';
filter?: string;
ref?: React.Ref<YourComponentRef>;
}
export interface YourComponentRef {
refetch: () => void;
}
export type YourEntityNode = NonNullable<
NonNullable<
YourPaginatedQuery['response']['yourEntities']
>['edges'][number]
>['node'];
// Scenario: Select resource group by name
<BAIAdminResourceGroupSelect
queryRef={queryRef}
placeholder="Select resource group"
onChange={(name) => setSelectedGroup(name)}
/>
const vfolderSelectRef = useRef<BAIVFolderSelectRef>(null);
<BAIVFolderSelect
ref={vfolderSelectRef}
valuePropName="id"
mode="multiple"
value={selectedFolderIds}
onChange={setSelectedFolderIds}
onClickVFolder={(id) => navigate(\`/folders/\${id}\`)}
/>
<Button onClick={() => vfolderSelectRef.current?.refetch()}>
Refresh
</Button>
<BAIVFolderSelect
filter={mergeFilterValues([
'status != "DELETE_COMPLETE"',
ownershipFilter ? \`ownership_type == "\${ownershipFilter}"\` : null,
])}
excludeDeleted
onChange={(ids) => handleSelection(ids)}
/>
Pattern A (Simple) when:
Pattern B (Dynamic) when:
Key insight: Pattern B is not about the value type (email vs ID vs name), but about dynamic control over query parameters and component behavior.
references/patterns/BAIAdminResourceGroupSelect.mdreferences/patterns/BAIUserSelect.mdreferences/patterns/BAIVFolderSelect.mdreferences/base/BAISelect.mdreferences/hooks/ (useFetchKey, useLazyPaginatedQuery, useDebouncedDeferredValue, useEventNotStable)references/helpers/ (relay-helpers, mergeFilterValues)YourEntitySelect.tsx
├── Imports (React, Relay, hooks, helpers)
├── Type definitions (Props, Ref, Node extraction)
├── Component with 'use memo'
│ ├── State management
│ ├── GraphQL queries
│ ├── Value-to-label mapping (Pattern B)
│ ├── useImperativeHandle (Pattern B)
│ ├── Options building
│ └── BAISelect integration
└── Export
Use comp: prefix for component translations:
t('comp:YourComponentSelect.PlaceHolder')
t('comp:YourComponentSelect.SelectEntity')
t('comp:YourComponentSelect.NoEntityFound')
For comprehensive examples and detailed implementation, refer to:
references/README.md - File overview and usage notesreferences/patterns/references/base/references/hooks/references/helpers/development
Find WebUI dev server address and Backend.AI API endpoint/credentials for testing. Trigger on: "which server", "connection info", "login credentials", "dev server URL", "API endpoint", "where to connect", "how to login", "test server", or when needing to interact with the running WebUI (screenshots, live checks, E2E).
tools
GraphQL/Relay integration patterns for Backend.AI WebUI React components. Covers useLazyLoadQuery, useFragment, useRefetchableFragment, fragment architecture (query orchestrator + fragment component), naming conventions, modern directives (@required, @alias), client directives (@since, @deprecatedSince, @skipOnClient), and query optimization.
development
# record-e2e-gif Skill Record Playwright e2e tests as GIF animations, one GIF per test case. ## Prerequisites - `ffmpeg` must be installed (`/opt/homebrew/bin/ffmpeg` on macOS) - Playwright dev server must be running. The endpoint is read from `e2e/envs/.env.playwright` (`E2E_WEBUI_ENDPOINT`). Do NOT hardcode the port — it varies per environment. ## How It Works 1. Run specified Playwright tests with `--video=on` (Playwright saves `.webm` per test in `test-results/`) 2. For each `.webm` fil
tools
Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.