fix(portal-shell): wrap sidebar in client component to respect RSC boundary

P1-1 regression introduced by layout.tsx RSC refactor: navigation.ts
exports `icon: LucideIcon` (function refs) which cannot cross the RSC
boundary from a Server Component to a Client Component.

Fix:
- Introduce ShellSidebar (Client Component) that owns the navigation
  filtering + icon refs entirely on the client side.
- layout.tsx (RSC) now only passes serializable strings (`role` and
  `permsBitmap`) to ShellSidebar; no function references cross the
  boundary.

Error before fix:
  Error: Functions cannot be passed directly to Client Components
  unless you explicitly expose it by marking it with "use server".
  {$$typeof: ..., render: function LayoutDashboard}

Refs: apps/portal-shell/ARCHITECTURE.md §7.2 AppFrame, §10 P1-1,
      §11.7 red line #5 (fail-closed identity).
This commit is contained in:
SpecialX
2026-07-22 12:47:00 +08:00
parent 98058eb16b
commit 03e3ec4f60
2 changed files with 54 additions and 18 deletions

View File

@@ -0,0 +1,40 @@
"use client";
import type { Role } from "@edu/shared-ts/contracts";
import { AppSidebar, type NavItem } from "./app-sidebar";
import { getNavigationItemsForRole } from "@/shared/lib/navigation";
import { batchCheckRoutePermission } from "@/shared/lib/route-permissions";
/**
* ShellSidebar - portal-shell 专用侧边栏容器Client Component
*
* 职责:在 Client 侧按 role + permsBitmap 过滤导航项,渲染 AppSidebar。
*
* 为何是 Client Component
* - navigation.ts 的 `icon` 字段是 lucide-react 组件(函数),
* 不能从 RSC 直接传给 Client ComponentNext.js RSC 边界限制)。
* - 将过滤逻辑放到 Client 侧icon 函数引用不出 Client 边界。
*
* 关联ARCHITECTURE.md §7.2 AppFrame / §10 P1-1
*/
export interface ShellSidebarProps {
role: Role;
permsBitmap: string;
}
export function ShellSidebar({
role,
permsBitmap,
}: ShellSidebarProps): React.ReactNode {
const roleItems = getNavigationItemsForRole(role);
// PREFIX 路由以 "/" 结尾,需同时检查 href 和 href+"/"
const pathsToCheck = roleItems.flatMap((i) => [i.href, `${i.href}/`]);
const permMap = batchCheckRoutePermission(pathsToCheck, permsBitmap, role);
const visibleItems: NavItem[] = roleItems
.filter((i) => permMap[i.href] === true || permMap[`${i.href}/`] === true)
.map((i) => ({ title: i.label, href: i.href, icon: i.icon }));
// layout 已按权限过滤AppSidebar 内部无需再次过滤
return <AppSidebar items={visibleItems} hasPermission={() => true} />;
}