Skip to content
Open
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
46 changes: 46 additions & 0 deletions .claude/rules/use-memo-directive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
description: When writing or editing React components or custom hooks
paths:
- "react/**/*.{tsx,ts}"
- "packages/backend.ai-ui/**/*.{tsx,ts}"
---

# 'use memo' Directive Rule

Always place `'use memo'` at the very top of the function body for **both React components and custom hooks** (`use*` functions).

## Why

This project uses the React Compiler in annotation mode (`babel-plugin-react-compiler`). The `'use memo'` directive opts the function in to full compiler optimization — automatic memoization of values, callbacks, and JSX without manual `useMemo`/`useCallback`. Omitting it from hooks means the hook's internals are not optimized, even if the components consuming it are.

## Pattern

```tsx
// ✅ Component — always add 'use memo'
function MyComponent({ id }: Props) {
'use memo';

return <div>{id}</div>;
}

// ✅ Custom hook — always add 'use memo'
const useMyData = (id: string) => {
'use memo';

return useQuery(id);
};

// ❌ Missing in hook — not optimized
const useMyData = (id: string) => {
return useQuery(id);
};
```

## Rules

1. Add `'use memo'` as the **first statement** in the function body — before any hooks, variables, or logic.
2. Apply to **both components and custom hooks** (`use*` naming convention).
3. Comments before the directive are allowed.
4. Use single or double quotes — **not backticks**.
5. **Never remove** an existing `'use memo'` directive.
6. Do not add `'use memo'` inside helper functions that are not components or hooks (plain utilities, event handlers, etc.).
26 changes: 20 additions & 6 deletions .github/instructions/react.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ These instructions apply to React components in the `/react` directory.

## React Compiler Optimization

### 'use memo' Directive (Recommended)
### 'use memo' Directive (Required)

- This project uses the new **React Compiler**.
- We actively use React directives such as `use memo` and `use client` at the top components (NOT files).
- We actively use React directives such as `use memo` and `use client` at the top of **component and custom hook** function bodies (NOT files).
- `use memo` is **intentional and valid** in this codebase.
- Even if tooling or TypeScript/ESLint shows something like:
- `Unknown directive: 'use memo'.`
Expand All @@ -20,8 +20,8 @@ These instructions apply to React components in the `/react` directory.

### What should do

- When generating or editing React components:
- Prefer adding `use memo` when it is beneficial for React Compiler.
- When generating or editing React components **or custom hooks** (`use*` functions):
- **Always** add `'use memo'` at the top of the function body.
- **Never** remove existing `use memo` directives.
- **Never** “fix” or “rename” `use memo` to something else.
- **Never** add comments suggesting that `use memo` is unknown, invalid, or deprecated.
Expand All @@ -37,7 +37,7 @@ The `'use memo'` directive has **strict placement requirements**:
- Only the first directive is processed; additional directives are ignored

```typescript
// ✅ Good: 'use memo' at the very beginning of function body
// ✅ Good: 'use memo' at the very beginning of component body
function MyComponent({ data }: Props) {
'use memo';

Expand All @@ -46,6 +46,14 @@ function MyComponent({ data }: Props) {
return <div>{data}</div>;
}

// ✅ Good: 'use memo' in custom hooks too
const useMyHook = (id: string) => {
'use memo';

const [value, setValue] = useState(null);
return value;
};

// ✅ Good: Comments before 'use memo' are OK
const AnotherComponent: React.FC<Props> = ({ data }) => {
// This component is optimized by React Compiler
Expand Down Expand Up @@ -74,6 +82,12 @@ function BacktickBad({ data }: Props) {
`use memo`; // ❌ Must use quotes, not backticks
return <div>{data}</div>;
}

// ❌ Bad: Missing 'use memo' in a new custom hook
function useData(id: string) {
// ❌ Should have 'use memo' at the top
return useSomeQuery(id);
}
```

### Manual Optimization Hooks (Use Sparingly)
Expand Down Expand Up @@ -1056,7 +1070,7 @@ When reviewing React code, check for:

### React Compiler & Optimization

- [ ] Component uses `'use memo'` directive if it's a new component
- [ ] Component and custom hook (`use*`) uses `'use memo'` directive if new
- [ ] No unnecessary `useMemo`/`useCallback` (prefer 'use memo' directive)
- [ ] `useEffectEvent` is used for non-reactive logic in Effects when appropriate

Expand Down
1 change: 1 addition & 0 deletions packages/backend.ai-ui/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const config: StorybookConfig = {
staticDirs: [
'./public',
{ from: '../../../resources/fonts', to: '/fonts' },
{ from: '../../../resources/icons', to: '/resources/icons' },
],
};
export default config;
35 changes: 35 additions & 0 deletions packages/backend.ai-ui/src/components/BAIImageWithFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
@license
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
*/
import React, { useState } from 'react';

export interface BAIImageWithFallbackProps extends Omit<
React.ImgHTMLAttributes<HTMLImageElement>,
'onError'
> {
src: string;
fallbackIcon: React.ReactNode;
alt: string;
}

const BAIImageWithFallback: React.FC<BAIImageWithFallbackProps> = ({
src,
fallbackIcon,
alt,
...props
}) => {
'use memo';
const [errorSrc, setErrorSrc] = useState<string | null>(null);
const hasError = errorSrc === src;

if (hasError) {
return <>{fallbackIcon}</>;
}

return (
<img {...props} src={src} alt={alt} onError={() => setErrorSrc(src)} />
);
};

export default BAIImageWithFallback;
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,10 @@ const meta: Meta<typeof BAIResourceNumberWithIcon> = {
},
decorators: [
(Story) => (
<BAIMetaDataProvider deviceMetaData={mockDeviceMetaData}>
<BAIMetaDataProvider
deviceMetaData={mockDeviceMetaData}
mergedResourceSlots={mockDeviceMetaData}
>
<Story />
</BAIMetaDataProvider>
),
Expand Down Expand Up @@ -395,6 +398,73 @@ export const WithSharedMemory: Story = {
),
};

export const ServerConfiguredIcon: Story = {
parameters: {
docs: {
description: {
story: `
Demonstrates how the component handles devices whose \`display_icon\` is **not** in the built-in \`knownDeviceIcons\` set
(\`nvidia\`, \`rocm\`, \`tpu\`, \`ipu\`, \`gaudi\`, \`furiosa\`, \`rebel\`, \`tenstorrent\`).

**Icon resolution order for unknown devices:**
1. \`display_icon\` is set but not in built-in set → fetches \`/resources/icons/{display_icon}.svg\` from the server
2. SVG file missing (404) → falls back to \`MicrochipIcon\`
3. \`display_icon\` not set at all → falls back to \`MicrochipIcon\`

> **Note:** The "SVG from server" row uses \`npu_generic.svg\` as a stand-in example
> since it already exists in \`/resources/icons/\` and is not part of \`knownDeviceIcons\`.
> In production, any server-configured accelerator with a custom \`display_icon\` name follows the same path.`,
},
},
},
decorators: [
(Story) => {
const extendedDeviceMetaData = {
...mockDeviceMetaData,
// display_icon set to a name that exists in /resources/icons/ but is
// NOT in knownDeviceIcons → loads /resources/icons/npu_generic.svg
'npu-generic.device': {
slot_name: 'npu-generic.device',
description: 'Generic NPU (server-configured icon)',
human_readable_name: 'NPU',
display_unit: 'NPU',
number_format: { binary: false, round_length: 0 },
display_icon: 'npu_generic',
},
// display_icon set to a name that does NOT exist → fallback to MicrochipIcon
'unknown.device': {
slot_name: 'unknown.device',
description: 'Unknown device (missing icon file)',
human_readable_name: 'Unknown',
display_unit: 'Unit',
number_format: { binary: false, round_length: 0 },
display_icon: 'nonexistent_icon',
},
};
return (
<BAIMetaDataProvider
deviceMetaData={extendedDeviceMetaData}
mergedResourceSlots={extendedDeviceMetaData}
>
<Story />
</BAIMetaDataProvider>
);
},
],
render: () => (
<BAIFlex direction="column" gap="md" align="start">
<BAIFlex gap="sm" align="center">
<span style={{ width: 240 }}>SVG from server (npu_generic.svg):</span>
<BAIResourceNumberWithIcon type="npu-generic.device" value="2" />
</BAIFlex>
<BAIFlex gap="sm" align="center">
<span style={{ width: 240 }}>Fallback (missing SVG file):</span>
<BAIResourceNumberWithIcon type="unknown.device" value="1" />
</BAIFlex>
</BAIFlex>
),
};

export const WithoutTooltip: Story = {
parameters: {
docs: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import BAIRocmIcon from '../icons/BAIRocmIcon';
import BAITenstorrentIcon from '../icons/BAITenstorrentIcon';
import BAITpuIcon from '../icons/BAITpuIcon';
import BAIFlex from './BAIFlex';
import BAIImageWithFallback from './BAIImageWithFallback';
import NumberWithUnit from './BAINumberWithUnit';
import BAIText from './BAIText';
import { ResourceSlotName, useBAIDeviceMetaData } from './provider';
import { ResourceSlotName, useBAIMetaData } from './provider';
import { theme, Tooltip, TooltipProps } from 'antd';
import * as _ from 'lodash-es';
import { CpuIcon, MemoryStickIcon, MicrochipIcon } from 'lucide-react';
Expand Down Expand Up @@ -50,7 +51,7 @@ const BAIResourceNumberWithIcon = ({
}: BAIResourceNumberWithIconProps) => {
'use memo';

const deviceMetaData = useBAIDeviceMetaData();
const { mergedResourceSlots: deviceMetaData } = useBAIMetaData();
const { token } = theme.useToken();

const formatAmount = (amount: string) => {
Expand Down Expand Up @@ -137,7 +138,8 @@ export const ResourceTypeIcon = ({
}: ResourceTypeIconProps) => {
'use memo';

const deviceMetaData = useBAIDeviceMetaData();
const { mergedResourceSlots: deviceMetaData } = useBAIMetaData();
const displayIcon = deviceMetaData[type]?.display_icon;

const getIconContent = () => {
if (type === 'cpu') {
Expand All @@ -155,14 +157,29 @@ export const ResourceTypeIcon = ({
);
}

const displayIcon = deviceMetaData[type]?.display_icon;

if (displayIcon && _.keys(knownDeviceIcons).includes(displayIcon)) {
return (
knownDeviceIcons[displayIcon as keyof typeof knownDeviceIcons] ?? null
);
}

if (displayIcon) {
return (
<BAIImageWithFallback
src={`/resources/icons/${displayIcon}.svg`}
alt={type}
width={size}
height={size}
style={{ alignSelf: 'center' }}
fallbackIcon={
<BAIFlex style={{ width: size, height: size }}>
<MicrochipIcon />
</BAIFlex>
}
/>
);
}

return (
<BAIFlex style={{ width: size, height: size }}>
<MicrochipIcon />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
convertUnitValue,
toFixedFloorWithoutTrailingZeros,
} from '../../helper';
import { useResourceSlotsDetails } from '../../hooks';
import { useBAIMetaData } from '../../hooks';
import BAIDoubleTag from '../BAIDoubleTag';
import BAIFlex from '../BAIFlex';
import BAIIntervalView from '../BAIIntervalView';
Expand Down Expand Up @@ -68,11 +68,12 @@ const BAIAgentTable: React.FC<BAIAgentTableProps> = ({
customizeColumns,
...tableProps
}) => {
'use memo';
const { t } = useTranslation();
const { token } = theme.useToken();
Comment thread
nowgnuesLee marked this conversation as resolved.
const baiClient = useConnectedBAIClient();

const { mergedResourceSlots } = useResourceSlotsDetails();
const { mergedResourceSlots } = useBAIMetaData();

const agents = useFragment(
graphql`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { BAIProjectSettingModalFragment$key } from '../../__generated__/BAIProje
import { BAIProjectSettingModalModifyMutation } from '../../__generated__/BAIProjectSettingModalModifyMutation.graphql';
import { BAIProjectSettingModalQuery } from '../../__generated__/BAIProjectSettingModalQuery.graphql';
import { convertToBinaryUnit } from '../../helper';
import { useErrorMessageResolver, useResourceSlotsDetails } from '../../hooks';
import { useErrorMessageResolver, useBAIMetaData } from '../../hooks';
import BAIModal, { BAIModalProps } from '../BAIModal';
import {
App,
Expand Down Expand Up @@ -66,10 +66,11 @@ const BAIProjectSettingModal = ({
projectFragment,
...modalProps
}: BAIProjectSettingModalProps) => {
'use memo';
const { token } = theme.useToken();
Comment thread
nowgnuesLee marked this conversation as resolved.
const { t } = useTranslation();
const deferredOpen = useDeferredValue(modalProps.open);
const { resourceSlotsInRG, deviceMetaData } = useResourceSlotsDetails();
const { resourceSlotsInRG, deviceMetaData } = useBAIMetaData();
const form = useRef<FormInstance<FormValues>>(null);
const { message } = App.useApp();
const { getErrorMessage } = useErrorMessageResolver();
Expand Down
2 changes: 2 additions & 0 deletions packages/backend.ai-ui/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { default as BAIImageWithFallback } from './BAIImageWithFallback';
export type { BAIImageWithFallbackProps } from './BAIImageWithFallback';
export { default as BAIBadge } from './BAIBadge';
export type { BAIBadgeProps } from './BAIBadge';
export { default as BAIBoardItemTitle } from './BAIBoardItemTitle';
Expand Down
Loading
Loading