mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
7653e6bfdb
## Summary
- Replace Next.js App Router with React Router v7.13.0 framework mode (Vite-based), eliminating the large `next` dependency from the web, CLI, and workflow metapackages
- Serve the web UI in-process from the CLI via Express instead of spawning `next start` as a child process
- Switch RPC transport from JSON to CBOR to preserve binary data types across the wire
- Replace `nuqs` URL state management with React Router's `useSearchParams`
- Replace Next.js server actions with an RPC resource route (`/api/rpc`) and a thin CBOR-based client
## Motivation
The `next` package is ~300MB installed and was the single largest dependency in the monorepo. It also required spawning a separate child process from the CLI to run the o11y web server, adding complexity around process lifecycle management, port readiness polling, and environment variable forwarding.
With React Router framework mode, the web package builds to a standard Express-compatible server bundle that the CLI can import and serve directly in its own process.
## What changed
**Framework swap (`@workflow/web`):**
- `next.config.ts` / `postcss.config.mjs` → `react-router.config.ts` / `vite.config.ts`
- `src/` directory → `app/` directory (React Router convention)
- `src/app/layout.tsx` + `layout-client.tsx` → `app/root.tsx`
- `src/app/page.tsx` → `app/routes/home.tsx`
- `src/app/run/[runId]/page.tsx` → `app/routes/run-detail.tsx`
- Path alias `@/` → `~/`
- Removed all `'use client'` / `'use server'` directives
**Data transport:**
- Server actions → RPC resource route at `/api/rpc` with CBOR encoding
- CBOR preserves `Uint8Array` and other binary types natively (no base64 overhead)
- Stream reading → dedicated `/api/stream/:streamId` resource route
**URL state:**
- `nuqs` (`useQueryState`) → `useSearchParams` from `react-router`
**Fonts:**
- `next/font/google` → Geist `.woff2` files referenced directly from `node_modules/geist` via `@font-face` in CSS
**CLI integration (`@workflow/cli`):**
- `import('@workflow/web/server').then(m => m.startServer(port))`
- No child process, no readiness polling, no cleanup handlers
**Radix UI compatibility:**
- `onSubmit` preventDefault on `AlertDialogContent` and `SheetContent` to prevent Radix's internal `<form method="dialog">` from triggering React Router route actions
- Catch-all action on root route for any stray POSTs
## Dependencies removed
- `next`, `swr`, `nuqs`, `@tailwindcss/postcss`
## Dependencies added
- `react-router` / `@react-router/dev` / `@react-router/node` / `@react-router/express` (all `7.13.0`)
- `express`, `vite`, `@tailwindcss/vite`, `cbor-x`, `isbot`, `cross-env`
- `geist` (devDep)
154 lines
3.8 KiB
TypeScript
154 lines
3.8 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react';
|
|
|
|
export interface UseTableSelectionOptions<T> {
|
|
/** Function to extract unique ID from each item */
|
|
getItemId: (item: T) => string;
|
|
}
|
|
|
|
export interface UseTableSelectionReturn<T> {
|
|
/** Set of currently selected item IDs */
|
|
selectedIds: Set<string>;
|
|
/** Whether a specific item is selected */
|
|
isSelected: (item: T) => boolean;
|
|
/** Toggle selection of a single item */
|
|
toggleSelection: (item: T) => void;
|
|
/** Select all items in the provided list */
|
|
selectAll: (items: T[]) => void;
|
|
/** Clear all selections */
|
|
clearSelection: () => void;
|
|
/** Toggle all items (select all if not all selected, otherwise clear) */
|
|
toggleSelectAll: (items: T[]) => void;
|
|
/** Number of selected items */
|
|
selectionCount: number;
|
|
/** Whether all provided items are selected */
|
|
isAllSelected: (items: T[]) => boolean;
|
|
/** Whether some but not all items are selected (for indeterminate state) */
|
|
isSomeSelected: (items: T[]) => boolean;
|
|
/** Select a specific item by ID */
|
|
selectById: (id: string) => void;
|
|
/** Deselect a specific item by ID */
|
|
deselectById: (id: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Hook for managing table row selection state.
|
|
* Provides a consistent interface for multi-select functionality across different tables.
|
|
*/
|
|
export function useTableSelection<T>({
|
|
getItemId,
|
|
}: UseTableSelectionOptions<T>): UseTableSelectionReturn<T> {
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
|
|
const isSelected = useCallback(
|
|
(item: T) => selectedIds.has(getItemId(item)),
|
|
[selectedIds, getItemId]
|
|
);
|
|
|
|
const toggleSelection = useCallback(
|
|
(item: T) => {
|
|
const id = getItemId(item);
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
[getItemId]
|
|
);
|
|
|
|
const selectAll = useCallback(
|
|
(items: T[]) => {
|
|
setSelectedIds(new Set(items.map(getItemId)));
|
|
},
|
|
[getItemId]
|
|
);
|
|
|
|
const clearSelection = useCallback(() => {
|
|
setSelectedIds(new Set());
|
|
}, []);
|
|
|
|
const isAllSelected = useCallback(
|
|
(items: T[]) => {
|
|
if (items.length === 0) return false;
|
|
return items.every((item) => selectedIds.has(getItemId(item)));
|
|
},
|
|
[selectedIds, getItemId]
|
|
);
|
|
|
|
const isSomeSelected = useCallback(
|
|
(items: T[]) => {
|
|
if (items.length === 0) return false;
|
|
const someSelected = items.some((item) =>
|
|
selectedIds.has(getItemId(item))
|
|
);
|
|
const allSelected = items.every((item) =>
|
|
selectedIds.has(getItemId(item))
|
|
);
|
|
return someSelected && !allSelected;
|
|
},
|
|
[selectedIds, getItemId]
|
|
);
|
|
|
|
const toggleSelectAll = useCallback(
|
|
(items: T[]) => {
|
|
if (isAllSelected(items)) {
|
|
clearSelection();
|
|
} else {
|
|
selectAll(items);
|
|
}
|
|
},
|
|
[isAllSelected, clearSelection, selectAll]
|
|
);
|
|
|
|
const selectById = useCallback((id: string) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.add(id);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const deselectById = useCallback((id: string) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const selectionCount = selectedIds.size;
|
|
|
|
return useMemo(
|
|
() => ({
|
|
selectedIds,
|
|
isSelected,
|
|
toggleSelection,
|
|
selectAll,
|
|
clearSelection,
|
|
toggleSelectAll,
|
|
selectionCount,
|
|
isAllSelected,
|
|
isSomeSelected,
|
|
selectById,
|
|
deselectById,
|
|
}),
|
|
[
|
|
selectedIds,
|
|
isSelected,
|
|
toggleSelection,
|
|
selectAll,
|
|
clearSelection,
|
|
toggleSelectAll,
|
|
selectionCount,
|
|
isAllSelected,
|
|
isSomeSelected,
|
|
selectById,
|
|
deselectById,
|
|
]
|
|
);
|
|
}
|