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:
@@ -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 (
|
||||
<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>
|
||||
|
||||
@@ -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 <AppSidebar items={visibleItems} hasPermission={() => true} />;
|
||||
}
|
||||
Reference in New Issue
Block a user