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 { headers } from "next/headers";
|
||||||
|
|
||||||
import type { Role } from "@edu/shared-ts/contracts";
|
import type { Role } from "@edu/shared-ts/contracts";
|
||||||
import { batchCheckRoutePermission } from "@/shared/lib/route-permissions";
|
import { ShellSidebar } from "@/shared/components/layout/shell-sidebar";
|
||||||
import { getNavigationItemsForRole } from "@/shared/lib/navigation";
|
|
||||||
import {
|
|
||||||
AppSidebar,
|
|
||||||
type NavItem,
|
|
||||||
} from "@/shared/components/layout/app-sidebar";
|
|
||||||
import { SidebarProvider } from "@/shared/components/layout/sidebar-provider";
|
import { SidebarProvider } from "@/shared/components/layout/sidebar-provider";
|
||||||
import { SiteHeader } from "@/shared/components/layout/site-header";
|
import { SiteHeader } from "@/shared/components/layout/site-header";
|
||||||
import { UserMenu } from "@/shared/components/layout/user-menu";
|
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)
|
* AppFrame - 全站页面框架(RSC,ARCHITECTURE.md §7.2 / §10 P1-1)
|
||||||
*
|
*
|
||||||
* 结构:SidebarProvider > AppSidebar + (SiteHeader + main)
|
* 结构:SidebarProvider > ShellSidebar + (SiteHeader + main)
|
||||||
* - 从 middleware 注入的请求头读取身份(fail-closed,§11.7 红线 #5)
|
*
|
||||||
* - batchCheckRoutePermission 按用户位图过滤导航项
|
* fail-closed(P0-2,§11.7 红线 #5):
|
||||||
* - PREFIX 路由以 "/" 结尾,需同时检查 href 和 href+"/"
|
* - 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({
|
export default async function ShellLayout({
|
||||||
children,
|
children,
|
||||||
@@ -38,17 +41,10 @@ export default async function ShellLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const role = roleHeader as Role;
|
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 (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar items={visibleItems} hasPermission={() => true} />
|
<ShellSidebar role={role} permsBitmap={permsBitmap} />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<SiteHeader actions={<UserMenu userId={userId} role={role} />} />
|
<SiteHeader actions={<UserMenu userId={userId} role={role} />} />
|
||||||
<main className="flex-1 overflow-y-auto p-4">{children}</main>
|
<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