docs/plans/2026-04-08-react-migration-playbook.md
This playbook captures the repo-specific patterns that worked well while migrating RevisionDetail from Vue to React.
Use this when migrating an existing frontend surface from Vue to React in frontend/.
The goal is not "remove all Vue in one PR". The goal is to move a bounded surface to React without breaking still-live Vue callers.
Do not rewrite an entire subsystem unless the target surface actually depends on that rewrite.
Migrate shared components first only when they are on the critical path for the target surface.
Good candidates:
Bad candidates:
Before deleting a Vue counterpart, check for live callers with rg.
Safe to delete:
Not safe to delete:
If a React wrapper still depends on an existing TS utility from the Vue side, that is fine. Do not force a rewrite just to remove the old directory name.
Default to the existing store and Connect stack first.
Prefer:
useVueState(getter) when React needs to subscribe to Vue reactive stateOnly introduce zustand or tanstack/query when there is a concrete problem, such as:
Do not add either library by default just because the target is React.
When migrating a route:
ReactPageMount.vue.This keeps the React page focused on rendering and avoids scattering route parsing across components.
For imperative libraries, prefer one stable integration seam over direct imports in React components.
Preferred pattern:
Avoid:
This mattered for Monaco. A direct async import("monaco-editor") inside a React effect passed locally but caused a CI-only failure under Node 24 because the late import resolved into a CSS-loading path outside the test's awaited boundary.
For migrated React wrappers:
Minimum verification for a migration PR:
pnpm --dir frontend fixpnpm --dir frontend checkpnpm --dir frontend type-checkpnpm --dir frontend testIf you add a new shared React wrapper, add focused tests for it before relying only on page-level verification.
Treat these as part of the migration, not cleanup after it:
frontend/src/react/locales/tsconfig updates for new React barrels or entry pointscheck-react-i18n and type-checking catch migration drift earlyfix, check, type-check, and test?RevisionDetail was a good pattern for future migrations:
These patterns must be followed in all React UI to maintain visual consistency.
Use only these Tailwind classes:
rounded-xs (2px) — inputs, buttons, tags, badges, alerts, checkboxes, small inline elementsrounded-sm (4px) — modals, dialogs, dropdowns, popovers, tooltips, bordered card/section containers, list containers with overflowrounded-full — pills, avatarsNever use: rounded, rounded-md, rounded-lg, rounded-xl, or any other radius value.
Always use <Input> (@/react/components/ui/input) instead of raw <input> for type="text", type="number", type="password", type="email", type="date", etc.
The only exception is when an input is intentionally borderless inside a custom wrapper (e.g., a search input inside a combo trigger, or an email prefix input inside a bordered div with a suffix).
import { Input } from "@/react/components/ui/input";
<Input type="number" value={count} onChange={...} className="w-24" />
@/react/components/ui/search-input)Use SearchInput for all filter/search inputs. Do NOT build inline search inputs with <Input> + <Search> icon.
import { SearchInput } from "@/react/components/ui/search-input";
<SearchInput
placeholder={t("common.filter-by-name")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
h-9, default placeholder t("common.type-to-search")flex-1 (full width); override with wrapperClassNameclassName (e.g., className="h-7" inside dropdowns)@/react/hooks/usePagedData)Use PagedTableFooter for all paginated tables. Do NOT build inline pagination.
import { PagedTableFooter } from "@/react/hooks/usePagedData";
<PagedTableFooter
pageSize={pageSize}
pageSizeOptions={pageSizeOptions}
onPageSizeChange={setPageSize}
hasMore={hasMore}
isFetchingMore={isFetchingMore}
onLoadMore={loadMore}
/>
@/react/components/ui/combobox)Generic select supporting single-select, multi-select, grouped options, search, and portal rendering.
import { Combobox } from "@/react/components/ui/combobox";
// Single-select
<Combobox value={selected} onChange={setSelected} options={options} />
// Multi-select
<Combobox multiple value={list} onChange={setList} options={options} />
// Inside modals
<Combobox value={val} onChange={setVal} options={options} portal />
@/react/components/RoleSelect)Built on Combobox. Use for all role selection.
import { RoleSelect } from "@/react/components/RoleSelect";
<RoleSelect value={roles} onChange={setRoles} /> // multi
<RoleSelect value={[role]} onChange={(r) => set(r[0])} multiple={false} /> // single
<RoleSelect value={roles} onChange={setRoles} scope="project" /> // project only
@/react/components/AccountMultiSelect)Multi-select for users, groups, and special accounts with server-side search.
@/react/components/UserAvatar)Renders a user avatar with color-coded initials.
IMPORTANT: When migrating Vue pages to React, always check the Vue 3.16.1 source for FeatureBadge, FeatureAttention, PermissionGuardWrapper, and ComponentPermissionGuard usage. React pages must have feature parity — never just disabled={!canX} without the guard wrapper.
| Component | Purpose | Usage |
|---|---|---|
FeatureBadge | Sparkles icon + tooltip for plan-gated features | Next to labels; inside buttons with clickable={false} className="mr-1 text-white inline-flex" |
FeatureAttention | Full-width banner for plan requirements | Top of page/section |
PermissionGuard | Tooltip wrapper for missing permissions | Inline (buttons): default; Block (sections): display="block" |
ComponentPermissionGuard | Error alert for gated components | Replaces content with permission error |
PermissionGuard supports two patterns:
// 1. Static children — use usePermissionCheck for disabled state
const [canEdit] = usePermissionCheck(["bb.settings.set"]);
<PermissionGuard permissions={["bb.settings.set"]}>
<Button disabled={!canEdit}>Edit</Button>
</PermissionGuard>
// 2. Render-prop children — like Vue PermissionGuardWrapper slot props
<PermissionGuard permissions={["bb.projects.update"]} project={project}>
{({ disabled }) => <Button disabled={disabled}>Save</Button>}
</PermissionGuard>
ComponentPermissionGuard replaces entire sections when permissions are missing:
<ComponentPermissionGuard
permissions={["bb.accessGrants.list"]}
project={project}
className="mx-4"
>
<AccessGrantsTable />
</ComponentPermissionGuard>
All inputs and buttons in the same row: h-9.
border-accent for active state, NOT ring-2 ring-accentoutline-hidden border-none shadow-noneGlobal thin scrollbars via CSS in tailwind.css. No per-component styling needed.
Use createPortal or pass portal prop to Combobox when inside overflow: hidden/auto containers.