mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
shell-redesign V1 attempt-3 Phase 5 ship-gate bounce: P0-1 + P0-2
V1 SHIP GATE BOUNCE — 2 P0 fixes from design-reviewer ship-gate walk.
P0-1 — /topology at 375x812 mobile crashed to error boundary
("Cannot read properties of undefined (reading 'length')").
Root cause: TopologyTableView called useNodeInventory inside
`scopedRigs.map((r) => ({ ..., inv: useNodeInventory(r.id) }))` — hooks
in a variable-count loop. At desktop the table mounted only after the
user clicked the table tab (rigs already resolved → consistent hook
count from first render). At mobile, P5-9 effectiveActive degrades
graph→table at FIRST render, mounting the table BEFORE useRigSummary
resolves: scopedRigs starts [] → no hooks called → rigs resolves to
[N] → next render calls N useNodeInventory hooks → React detects hook
count change → crash.
Fix: switched to `useQueries({ queries: scopedRigs.map(...) })` from
React Query — single hook call regardless of array length, hook order
stable across renders. Added `fetchNodeInventory` helper inline (the
useNodeInventory hook is now unused by this file). Memo dependency
also tightened from JSON.stringify-of-data to direct
[scopedRigs, inventoryResults] references.
P0-2 — P5-4 NodeDetailPanel orphan whitespace not actually fixed
(SharedDetailDrawer 38rem chrome correct; inner panel pinned to
viewport-right with `absolute inset-y-0 right-0 z-20 w-80`, leaving
~288px / 30% orphan whitespace inside the drawer).
Root cause: NodeDetailPanel's root <aside> still carried legacy
self-pinning class string from the pre-refactor era when it was a
right-sidebar overlay. P5-4 migrated the DrawerSelection discriminator
('node' → 'seat-detail') but did NOT rewrite NodeDetailPanel's own
positioning. SeatDetailViewer wrapped it but couldn't override the
absolute self-pin from the parent.
Fix: switched the root <aside> from
`absolute inset-y-0 right-0 z-20 w-80 border-l border-stone-300/25 ...`
to `relative w-full h-full ...` — fill-parent layout. Verified via
grep `<NodeDetailPanel`: the only production caller is SeatDetailViewer
(P5-4 retired all other paths), so the legacy absolute positioning had
no remaining consumer that depended on it. Background + backdrop
preserved; border-l dropped because parent VellumSheet already supplies
the canonical 1px outline-variant left edge.
NEW packages/ui/test/p5-ship-gate-bounce.test.tsx (3 regression guards):
- P0-1: TopologyTableView mounts cleanly when /api/rigs/summary has not
yet resolved + then resolves to [3 rigs]; pre-fix this sequence
crashed with rules-of-hooks. Source-assertion (ritual #9): useQueries
imported + called; legacy `inv: useNodeInventory` pattern absent.
- P0-2: NodeDetailPanel.tsx source has `relative w-full h-full` on root
<aside>; legacy `absolute inset-y-0 right-0` + `w-80` patterns absent
from code (comments preserved as historical record).
Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-ship-gate-bounce + p5-mobile-polish + p5-topology-terminal +
drawer-primitives + node-detail-panel: all pass
- Full vitest suite: 679/681 PASS (1 pre-existing library-review
Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
483c23f3e2
commit
668e156609
3 files changed
+182
-16
No files matched your search
@@ -49,9 +49,17 @@ export function NodeDetailPanel({ rigId, logicalId, onClose }: NodeDetailPanelPr
|
||||
};
|
||||
|
||||
return (
|
||||
// V1 attempt-3 Phase 5 P5-9 ship-gate bounce P0-2: switch from
|
||||
// `absolute inset-y-0 right-0 z-20 w-80` legacy self-pinning (which
|
||||
// left ~288px orphan whitespace inside the 38rem drawer because the
|
||||
// panel pinned itself to viewport-right with w-80=320px) to
|
||||
// `relative w-full h-full` fill-parent. Only production caller is
|
||||
// SeatDetailViewer (verified via grep `<NodeDetailPanel`) which mounts
|
||||
// inside the canonical 38rem VellumSheet drawer chrome and supplies
|
||||
// its own width context. Background + backdrop preserved.
|
||||
<aside
|
||||
data-testid="node-detail-panel"
|
||||
className="absolute inset-y-0 right-0 z-20 w-80 border-l border-stone-300/25 bg-[rgba(250,249,245,0.035)] supports-[backdrop-filter]:bg-[rgba(250,249,245,0.018)] backdrop-blur-[14px] backdrop-saturate-75 shadow-[-6px_0_14px_rgba(46,52,46,0.04)] flex flex-col overflow-hidden"
|
||||
className="relative w-full h-full bg-[rgba(250,249,245,0.035)] supports-[backdrop-filter]:bg-[rgba(250,249,245,0.018)] flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-4 py-3 border-b border-stone-300/35 shrink-0">
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
// V1 attempt-3 Phase 3 — Topology table view per topology-table-view.md + SC-25.
|
||||
//
|
||||
// Tanstack-backed table; row per agent across topology, scoped by URL.
|
||||
//
|
||||
// V1 attempt-3 Phase 5 P5-9 ship-gate bounce P0-1: rules-of-hooks fix.
|
||||
// Previous shape used `scopedRigs.map((r) => useNodeInventory(r.id))` which
|
||||
// calls hooks in a loop with variable count — when scopedRigs grew from 0
|
||||
// (initial render before useRigSummary resolves) to N (after resolution),
|
||||
// React detected the hook count change and threw "Cannot read properties
|
||||
// of undefined (reading 'length')" downstream. This crashed /topology at
|
||||
// 375x812 mobile because P5-9 mounts the table immediately at first
|
||||
// render (graph view-mode degraded to table for narrow viewports) BEFORE
|
||||
// rigs data is available. Switched to `useQueries` from React Query —
|
||||
// single hook call regardless of array length.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -12,12 +23,19 @@ import {
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useRigSummary } from "../../hooks/useRigSummary.js";
|
||||
import { useNodeInventory, type NodeInventoryEntry } from "../../hooks/useNodeInventory.js";
|
||||
import type { NodeInventoryEntry } from "../../hooks/useNodeInventory.js";
|
||||
import { VellumInput } from "../ui/vellum-input.js";
|
||||
import { StatusPip } from "../ui/status-pip.js";
|
||||
import { inferPodName } from "../../lib/display-name.js";
|
||||
|
||||
async function fetchNodeInventory(rigId: string): Promise<NodeInventoryEntry[]> {
|
||||
const res = await fetch(`/api/rigs/${encodeURIComponent(rigId)}/nodes`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface AgentRow {
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
@@ -54,25 +72,35 @@ const COLUMNS: ColumnDef<AgentRow>[] = [
|
||||
|
||||
export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
|
||||
const { data: rigs } = useRigSummary();
|
||||
const scopedRigs = rigIdScope
|
||||
? rigs?.filter((r) => r.id === rigIdScope) ?? []
|
||||
: rigs ?? [];
|
||||
const scopedRigs = useMemo(
|
||||
() =>
|
||||
rigIdScope
|
||||
? rigs?.filter((r) => r.id === rigIdScope) ?? []
|
||||
: rigs ?? [],
|
||||
[rigs, rigIdScope],
|
||||
);
|
||||
|
||||
// Fetch node inventories per rig in scope.
|
||||
const inventoryQueries = scopedRigs.map((r) => ({
|
||||
rigId: r.id,
|
||||
rigName: r.name,
|
||||
inv: useNodeInventory(r.id),
|
||||
}));
|
||||
// P0-1 fix: useQueries replaces the .map(useNodeInventory) loop. Single
|
||||
// hook call regardless of scopedRigs length — React's hook order stays
|
||||
// stable across renders even when rigs grows from undefined → [N].
|
||||
const inventoryResults = useQueries({
|
||||
queries: scopedRigs.map((r) => ({
|
||||
queryKey: ["rig", r.id, "nodes"] as const,
|
||||
queryFn: () => fetchNodeInventory(r.id),
|
||||
})),
|
||||
});
|
||||
|
||||
const data: AgentRow[] = useMemo(() => {
|
||||
const rows: AgentRow[] = [];
|
||||
for (const q of inventoryQueries) {
|
||||
const nodes: NodeInventoryEntry[] = q.inv.data ?? [];
|
||||
for (let i = 0; i < scopedRigs.length; i++) {
|
||||
const rig = scopedRigs[i];
|
||||
const result = inventoryResults[i];
|
||||
if (!rig || !result) continue;
|
||||
const nodes: NodeInventoryEntry[] = result.data ?? [];
|
||||
for (const n of nodes) {
|
||||
rows.push({
|
||||
rigId: q.rigId,
|
||||
rigName: q.rigName,
|
||||
rigId: rig.id,
|
||||
rigName: rig.name,
|
||||
podName: inferPodName(n.logicalId) ?? "default",
|
||||
logicalId: n.logicalId,
|
||||
sessionName: n.canonicalSessionName ?? n.logicalId,
|
||||
@@ -83,7 +111,7 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [JSON.stringify(inventoryQueries.map((q) => q.inv.data ?? []))]);
|
||||
}, [scopedRigs, inventoryResults]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// V1 attempt-3 Phase 5 ship-gate bounce P0-1 + P0-2 regression guards.
|
||||
//
|
||||
// P0-1: TopologyTableView previously called useNodeInventory inside a
|
||||
// scopedRigs.map() — when rigs grew from undefined → [N], hook count
|
||||
// jumped 0 → N which is a Rules-of-Hooks violation. At desktop this was
|
||||
// masked because table view-mode mounted only after the user clicked,
|
||||
// by which time rigs was already resolved (consistent count from first
|
||||
// render). At mobile (P5-9), /topology graph degrades to table at first
|
||||
// render, so the table mounts BEFORE rigs resolves → crash. Fixed by
|
||||
// switching to `useQueries` (single hook call regardless of array length).
|
||||
//
|
||||
// P0-2: NodeDetailPanel previously self-pinned with
|
||||
// `absolute inset-y-0 right-0 z-20 w-80` (320px) which left ~288px
|
||||
// orphan whitespace inside the 38rem (608px) drawer chrome. Fixed by
|
||||
// switching to fill-parent (`relative w-full h-full`).
|
||||
//
|
||||
// Both regressions break the founder-noticed UX from the V1 ship-gate
|
||||
// walk; tests must permanently guard.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function withQueryClient(ui: React.ReactNode) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P0-1: TopologyTableView no-crash regression — mount before rigs resolve.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
import { TopologyTableView } from "../src/components/topology/TopologyTableView.js";
|
||||
|
||||
describe("TopologyTableView P0-1 regression: no rules-of-hooks crash on first-render-before-rigs-resolved", () => {
|
||||
it("mounts cleanly when /api/rigs/summary has not yet resolved (mobile P5-9 first-render path)", async () => {
|
||||
let resolveSummary: ((value: unknown) => void) | null = null;
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/api/rigs/summary")) {
|
||||
// Hold the summary promise so first render sees rigs=undefined,
|
||||
// then resolve it → second render sees rigs=[N]. Pre-fix, this
|
||||
// sequence crashed (hook count 0 → N).
|
||||
return new Promise((resolve) => {
|
||||
resolveSummary = (value) => {
|
||||
resolve(new Response(JSON.stringify(value)));
|
||||
};
|
||||
});
|
||||
}
|
||||
const m = url.match(/\/api\/rigs\/([^/]+)\/nodes/);
|
||||
if (m) {
|
||||
return new Response(JSON.stringify([]));
|
||||
}
|
||||
return new Response("[]");
|
||||
});
|
||||
|
||||
const { container } = withQueryClient(<TopologyTableView />);
|
||||
// Initial render: rigs not yet resolved; component must NOT crash.
|
||||
expect(container.querySelector("[data-testid='topology-table-view']")).toBeTruthy();
|
||||
|
||||
// Now resolve the summary with multiple rigs — hook count would
|
||||
// change under the old shape; under the fix (useQueries) it stays at 1.
|
||||
resolveSummary!([
|
||||
{ id: "rig-1", name: "rig-1" },
|
||||
{ id: "rig-2", name: "rig-2" },
|
||||
{ id: "rig-3", name: "rig-3" },
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
// Component still alive after rigs resolution.
|
||||
expect(container.querySelector("[data-testid='topology-table-view']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("source asserts useQueries replaces the .map(useNodeInventory) loop (ritual #9)", () => {
|
||||
const src = readFileSync(
|
||||
path.resolve(
|
||||
__dirname,
|
||||
"../src/components/topology/TopologyTableView.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
// useQueries import + call present.
|
||||
expect(src).toMatch(/import\s*\{[^}]*useQueries[^}]*\}\s*from\s*["']@tanstack\/react-query["']/);
|
||||
expect(src).toMatch(/useQueries\s*\(/);
|
||||
// Negative-assertion: legacy `.map((r) => ({ ..., inv: useNodeInventory(r.id) }))`
|
||||
// pattern absent — strip comments first.
|
||||
const codeOnly = src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/[^\n]*\n/gm, "");
|
||||
expect(codeOnly).not.toMatch(/inv:\s*useNodeInventory/);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P0-2: NodeDetailPanel fill-parent regression — no self-pinning.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("NodeDetailPanel P0-2 regression: fills parent (no absolute self-pinning)", () => {
|
||||
it("source uses 'relative w-full h-full' on the root <aside> (NOT 'absolute inset-y-0 right-0 w-80')", () => {
|
||||
const src = readFileSync(
|
||||
path.resolve(__dirname, "../src/components/NodeDetailPanel.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// Strip comments so historical mentions of the legacy class string
|
||||
// (preserved in the explanatory comment block) don't trigger the
|
||||
// negative assertion.
|
||||
const codeOnly = src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/[^\n]*\n/gm, "");
|
||||
// Negative-assertion: legacy `absolute inset-y-0 right-0 ... w-80`
|
||||
// pinning class string MUST NOT survive in code.
|
||||
expect(codeOnly).not.toMatch(/absolute\s+inset-y-0\s+right-0/);
|
||||
expect(codeOnly).not.toMatch(/\bw-80\b/);
|
||||
// Positive companion: fill-parent layout present on the root <aside>.
|
||||
expect(codeOnly).toMatch(/relative\s+w-full\s+h-full/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user