From 03e3ec4f606d34fa8afcfde92d8aa8a7e88d80e0 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:47:00 +0800 Subject: [PATCH] fix(portal-shell): wrap sidebar in client component to respect RSC boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/portal-shell/src/app/shell/layout.tsx | 32 +++++++-------- .../components/layout/shell-sidebar.tsx | 40 +++++++++++++++++++ 2 files changed, 54 insertions(+), 18 deletions(-) create mode 100644 apps/portal-shell/src/shared/components/layout/shell-sidebar.tsx diff --git a/apps/portal-shell/src/app/shell/layout.tsx b/apps/portal-shell/src/app/shell/layout.tsx index 45a69f4..20dc772 100644 --- a/apps/portal-shell/src/app/shell/layout.tsx +++ b/apps/portal-shell/src/app/shell/layout.tsx @@ -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 - 全站页面框架(RSC,ARCHITECTURE.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-closed(P0-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 函数引用由 ShellSidebar(Client 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 ( - true} /> +
} />
{children}
diff --git a/apps/portal-shell/src/shared/components/layout/shell-sidebar.tsx b/apps/portal-shell/src/shared/components/layout/shell-sidebar.tsx new file mode 100644 index 0000000..e675778 --- /dev/null +++ b/apps/portal-shell/src/shared/components/layout/shell-sidebar.tsx @@ -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 Component(Next.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 true} />; +}