# Add Feature

> Add a new UI feature to the nvcf-ui app — folder structure, routing, data loading, lazy-loading, testing, and completion checklist. Use when creating a new feature folder, adding a page or view, wiring routes, building a new screen, or deciding whether to extend an existing feature vs create a new one. Also use when the user asks to build something new in the UI, even if they don't say "feature" explicitly.

- Skill: `nvidia/add-feature` (Agent Skill)
- Install (CLI): `npx skillmds@latest add nvidia/add-feature`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nvidia/add-feature/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: Apache-2.0
- Author: NVIDIA (https://skillmd.com/u/nvidia)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/nvidia/add-feature

---


# Adding a UI Feature

For all component selection, layout decisions, and styling — invoke the `kaizen-ui` skill.

## Gotchas

- This project uses **code-based routing**, not file-based. Do not use `createFileRoute` or the TanStack Router Vite plugin — use `createRoute` and `createLazyRoute`.
- View components are named by view type (e.g. `WidgetsList`, `WidgetDetail`), never with a `Page` suffix.
- `router.tsx` is pure composition — it only imports and wires route trees. Do not put route definitions or loaders in `router.tsx`.
- `rootRoute.tsx` is separate from `router.tsx` to avoid circular imports. Layout routes that span multiple features live at `src/` root, not in a feature folder.
- Generated hooks, schemas, and mocks live in `ui/src/generated/` — never edit these files. If the endpoint you need doesn't have generated hooks, update the appropriate spec in `spec/` (`control-plane-openapi.yaml`, `nvcf-openapi.yaml`, or `sis-openapi.yaml`) then run `task generate`.
- All source files need Apache 2.0 license headers. Run `task license:add` before committing.

## When to Create a New Feature Folder

- **New domain** (new data, new concepts, new API surface) → new feature folder
- **Composition of existing domains** (e.g. a home page assembling widgets) → new thin feature folder
- **Extension of an existing domain** (e.g. a "Create Function" page) → new route/component in the existing feature folder

## Feature Folder Structure

A feature is a **domain boundary**, not a route. Each feature is self-contained under `ui/src/features/<name>/`:

```
features/<name>/
├── components/         # Smaller building blocks (badges, cards)
├── hooks/              # Feature-scoped hooks
├── types/              # Feature-scoped types
├── routes.tsx          # Route definitions, loaders, search validation (optional)
├── <View>.tsx          # Route-level view components (PascalCase, named by view type)
└── utils.ts            # Feature-scoped helpers (when needed)
```

- Not every subfolder is required — add `components/`, `hooks/`, `types/` only when needed
- A feature may not have routes at all — it may only export hooks and components for other features
- Helpers go inline in the component file by default. Extract to a feature-root file only when shared across multiple files in the same feature

## Route Splitting Pattern

Each route is split across two files:

**`routes.tsx`** — route definition, search params, loader. In the main bundle.

```tsx
import { createRoute } from "@tanstack/react-router";
import { appLayoutRoute } from "~/appLayout";
import { queryClient } from "~/lib/queryClient";
import { getGetWidgetsQueryOptions } from "~/generated/api/widgets/widgets";
import { GetWidgetsParams } from "~/generated/model/getWidgetsParams.zod";

const widgetsRoute = createRoute({
  getParentRoute: () => appLayoutRoute,
  path: "widgets",
  validateSearch: GetWidgetsParams.catch({ status: "active" }),
  loaderDeps: ({ search }) => search,
  loader: ({ deps }) =>
    queryClient.ensureQueryData({
      ...getGetWidgetsQueryOptions(deps),
      revalidateIfStale: true,
    }),
}).lazy(() => import("./WidgetsList").then((m) => m.WidgetsListRoute));

export const widgetsRouteTree = widgetsRoute;
```

- Use generated Zod schemas for `validateSearch` when params map to API query params. For UI-only search params, define inline.
- `loaderDeps` declares which search params the loader depends on — the loader re-runs when these change.
- `ensureQueryData` with `revalidateIfStale: true` avoids flashing stale content.
- For multiple data sources: `ensureQueryData` (awaited) for primary data, `prefetchQuery` (no await) for below-the-fold data.

**`<View>.tsx`** — lazy-loaded component, pending, and error states.

```tsx
import { createLazyRoute } from "@tanstack/react-router";
import { useGetWidgetsSuspense } from "~/generated/api/widgets/widgets";

function WidgetsList() {
  const { status } = widgetsRoute.useSearch();
  const { data } = useGetWidgetsSuspense({ status });
  // render...
}

export const WidgetsListRoute = createLazyRoute("/widgets")({
  component: WidgetsList,
  pendingComponent: () => <div>Loading...</div>,
});
```

- Use suspense hook variants (`useGetWidgetsSuspense`) — the loader already ensured data, so these resolve immediately or suspend if cache is stale.
- For prefetched (non-blocking) data, wrap the consumer in a `<Suspense>` boundary with a fallback.

## Lazy-Loading Rules

- Page routes with view code → always lazy-load via `createLazyRoute`
- Layout routes that just render `<Outlet />` → keep in main bundle
- Components within a page → never lazy-load; already in the route's chunk

## Layout Routes

- Single feature → in that feature's `routes.tsx`
- Multiple features → at `src/` root alongside `rootRoute.tsx`

**Tabbed views**: use a layout route with `<Outlet />`, each tab is a child route with its own loader and lazy-loaded component.

## Steps

1. Check for existing components/patterns that could be reused or graduated before building new ones
2. Create `features/<name>/` with the relevant files — a view component and `routes.tsx` if the feature has routes, or just `components/`/`hooks/` if routeless
3. If the feature has routes: define routes in `routes.tsx` using `createRoute` with `.lazy()`, then wire the subtree into `router.tsx`. If it introduces a new top-level route, add a `NavItem` entry in `rootRoute.tsx`
4. Use generated hooks and query options for data fetching
5. Add tests for view components and any non-trivial logic. Use `renderWithRouter` from `~/testing/render` for route-level views, standard `render` for standalone components
6. Add MSW scenarios in `mocks/scenarios/<name>/` for key UI states
7. Run `task license:add` to apply license headers to new files

## Validate Before Finishing

1. Run `task ui:lint:fix` and fix any errors
2. Run `task ui:test` and verify tests pass
3. If either fails, fix the issues and rerun until clean

