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:
mvschwarzandClaude Opus 4.7 committed 2026-05-06 18:23:39 -07:00
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/);
});
});