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

@@ -1,12 +1,7 @@
import { headers } from "next/headers";
import type { Role } from "@edu/shared-ts/contracts";
import { batchCheckRoutePermission } from "@/shared/lib/route-permissions";
import { getNavigationItemsForRole } from "@/shared/lib/navigation";
import {
AppSidebar,
type NavItem,
} from "@/shared/components/layout/app-sidebar";
import { ShellSidebar } from "@/shared/components/layout/shell-sidebar";
import { SidebarProvider } from "@/shared/components/layout/sidebar-provider";
import { SiteHeader } from "@/shared/components/layout/site-header";
import { UserMenu } from "@/shared/components/layout/user-menu";
@@ -14,10 +9,18 @@ import { UserMenu } from "@/shared/components/layout/user-menu";
/**
* AppFrame - 全站页面框架RSCARCHITECTURE.md §7.2 / §10 P1-1
*
* 结构SidebarProvider > AppSidebar + (SiteHeader + main)
* - 从 middleware 注入的请求头读取身份fail-closed§11.7 红线 #5
* - batchCheckRoutePermission 按用户位图过滤导航项
* - PREFIX 路由以 "/" 结尾,需同时检查 href 和 href+"/"
* 结构SidebarProvider > ShellSidebar + (SiteHeader + main)
*
* fail-closedP0-2§11.7 红线 #5
* - middleware 已保证到达此处的请求必带 x-user-id / x-user-role / x-user-permissions
* - 头缺失 = middleware 未运行(异常路径)→ 抛错触发 error.tsx禁止默认 teacher
*
* RSC 边界P1-1 修正):
* - 本 layout 是 RSC仅向 Client Components 传可序列化数据string
* - 导航项过滤 + icon 函数引用由 ShellSidebarClient Component持有
* - 不直接传 lucide icon 或 hasPermission 函数给 Client Components
*
* 关联portal-shell ARCHITECTURE.md §7.2、§10 P1-1、§11.7 红线 #5
*/
export default async function ShellLayout({
children,
@@ -38,17 +41,10 @@ export default async function ShellLayout({
}
const role = roleHeader as Role;
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 }));
return (
<SidebarProvider>
<AppSidebar items={visibleItems} hasPermission={() => true} />
<ShellSidebar role={role} permsBitmap={permsBitmap} />
<div className="flex flex-1 flex-col">
<SiteHeader actions={<UserMenu userId={userId} role={role} />} />
<main className="flex-1 overflow-y-auto p-4">{children}</main>

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} />;
}