kanban-dev-webapp/src/components/shell/kanban-nav.tsx
Dave 5c26f39f2c fix(nav): avoid Kanban flyout state update racing with RSC navigation
A click outside the Kanban sidebar flyout called setOpen(false) even when
the click was on a Link, so the deferred state update landed during the
Link navigation's RSC stream and corrupted Turbopack's flight client
(vercel/next.js#92362).

Skip the close when the click target is inside an <a> and flush the close
synchronously from the Link's own onClick so the state update completes
before navigation, not during RSC processing.
2026-09-17 11:15:32 +02:00

111 lines
No EOL
4.1 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import Link from "next/link";
import { Kanban } from "lucide-react";
import { cn } from "@/lib/cn";
import type { SidebarProjectGroup } from "@/lib/data/board";
import type { Role } from "@/lib/pb/types";
/**
* The sidebar's Kanban entry.
*
* Not a Link like the rest of the rail: clicking it opens a flyout beside the
* rail, and picking a project from it navigates straight into that project's
* board. This is the one nav item that is a picker rather than a destination,
* so it owns its own open/closed state rather than reading currentPath.
*/
export function KanbanNav({
role,
groups,
active,
}: {
role: Role;
groups: SidebarProjectGroup[];
active: boolean;
}) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onPointerDown(event: PointerEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
// Skip closing when the click is on an anchor. The Link's own
// onClick closes the flyout via flushSync below, and racing a
// deferred state update with the navigation's RSC stream
// corrupts Turbopack's flight client (vercel/next.js#92362).
if ((event.target as HTMLElement).closest("a")) return;
setOpen(false);
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="true"
className={cn(
"flex w-[60px] flex-col items-center gap-1 rounded-xl px-1 py-2.5 transition-colors",
active || open
? "bg-panel text-text-primary shadow-[var(--shadow-tile)]"
: "text-text-muted hover:text-text-secondary"
)}
>
<Kanban size={18} strokeWidth={1.75} />
<span className="text-[10px] font-medium leading-none">Kanban</span>
</button>
{open && (
<div className="absolute left-[68px] top-0 z-20 max-h-[80vh] w-64 overflow-y-auto rounded-xl border border-border-subtle bg-panel p-2 shadow-[var(--shadow-panel)]">
{groups.length === 0 ? (
<p className="p-2 text-xs text-text-muted">No projects yet.</p>
) : (
groups.map((group) => (
<div key={group.client.id} className="mb-2 last:mb-0">
{/* A client manager only ever has one group — their own — so a
header naming their own client would be pure noise. */}
{role === "owner" && (
<p className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-text-muted">
{group.client.name}
</p>
)}
{group.projects.map((project) => (
<Link
key={project.id}
href={`/p/${group.client.slug}/${project.slug}`}
onClick={() => {
// Force the close to land before navigation, not
// during RSC stream processing — see #92362 above.
flushSync(() => setOpen(false));
}}
className="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised hover:text-text-primary"
>
<span className="truncate">{project.name}</span>
{project.kind === "prototype" && (
<span className="shrink-0 rounded-full bg-accent-soft px-1.5 py-0.5 text-[10px] font-medium text-accent">
Prototype
</span>
)}
</Link>
))}
</div>
))
)}
</div>
)}
</div>
);
}