diff --git a/packages/admin-next/src/components/resource-view.tsx b/packages/admin-next/src/components/resource-view.tsx
index 3741f6e..fe9ff44 100644
--- a/packages/admin-next/src/components/resource-view.tsx
+++ b/packages/admin-next/src/components/resource-view.tsx
@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
+import Link from "next/link";
import type {
ActionButton,
ActionType,
@@ -185,12 +186,12 @@ function ResourceDetail({
return (
From 32d89a4e91ef79fb940cdb2f8bdee37091a9d769 Mon Sep 17 00:00:00 2001
From: sirily11 <32106111+sirily11@users.noreply.github.com>
Date: Tue, 7 Jul 2026 05:08:15 +0800
Subject: [PATCH 5/6] fix: avoid sidebar collapse flicker
---
.../src/components/admin-shell.test.tsx | 13 ++++++-
.../admin-next/src/components/admin-shell.tsx | 34 +++++++++++++++----
packages/admin-next/src/server/admin-app.tsx | 6 ++++
3 files changed, 45 insertions(+), 8 deletions(-)
diff --git a/packages/admin-next/src/components/admin-shell.test.tsx b/packages/admin-next/src/components/admin-shell.test.tsx
index 487098d..bee6149 100644
--- a/packages/admin-next/src/components/admin-shell.test.tsx
+++ b/packages/admin-next/src/components/admin-shell.test.tsx
@@ -25,13 +25,16 @@ const noopActions: AdminActions = {
submitAction: vi.fn(),
};
-function renderShell() {
+function renderShell({
+ initialSidebarCollapsed,
+}: { initialSidebarCollapsed?: boolean } = {}) {
return render(
,
);
}
@@ -39,15 +42,23 @@ function renderShell() {
afterEach(() => {
cleanup();
window.localStorage.clear();
+ document.cookie = "ag_sidebar_collapsed=; path=/; max-age=0";
});
describe("AdminShell", () => {
+ it("uses the server-provided collapsed sidebar state on initial render", () => {
+ renderShell({ initialSidebarCollapsed: true });
+
+ expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeTruthy();
+ });
+
it("restores the persisted collapsed sidebar state after remount", async () => {
renderShell();
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
expect(window.localStorage.getItem("ag:sidebar-collapsed")).toBe("true");
+ expect(document.cookie).toContain("ag_sidebar_collapsed=true");
expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeTruthy();
cleanup();
diff --git a/packages/admin-next/src/components/admin-shell.tsx b/packages/admin-next/src/components/admin-shell.tsx
index 93df1dc..203f566 100644
--- a/packages/admin-next/src/components/admin-shell.tsx
+++ b/packages/admin-next/src/components/admin-shell.tsx
@@ -34,16 +34,25 @@ export interface AdminShellProps {
title?: React.ReactNode;
/** Custom content rendered at the top-right of the app header. */
headerActions?: React.ReactNode;
+ /** Initial desktop sidebar state read by the server to avoid hydration flicker. */
+ initialSidebarCollapsed?: boolean;
}
const SIDEBAR_COLLAPSED_STORAGE_KEY = "ag:sidebar-collapsed";
+const SIDEBAR_COLLAPSED_COOKIE_KEY = "ag_sidebar_collapsed";
-function readPersistedSidebarCollapsed(): boolean {
- if (typeof window === "undefined") return false;
+const useIsomorphicLayoutEffect =
+ typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
+
+function readPersistedSidebarCollapsed(): boolean | null {
+ if (typeof window === "undefined") return null;
try {
- return window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "true";
+ const value = window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
+ if (value === "true") return true;
+ if (value === "false") return false;
+ return null;
} catch {
- return false;
+ return null;
}
}
@@ -56,6 +65,13 @@ function persistSidebarCollapsed(collapsed: boolean): void {
} catch {
// Ignore storage failures; the in-memory collapsed state still works.
}
+ try {
+ document.cookie = `${SIDEBAR_COLLAPSED_COOKIE_KEY}=${String(
+ collapsed,
+ )}; path=/; max-age=31536000; SameSite=Lax`;
+ } catch {
+ // Ignore cookie failures; localStorage still covers client remounts.
+ }
}
/** Look up a lucide icon by its kebab/pascal name, with a sane fallback. */
@@ -80,8 +96,11 @@ export function AdminShell({
error,
title,
headerActions,
+ initialSidebarCollapsed = false,
}: AdminShellProps): React.JSX.Element {
- const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false);
+ const [sidebarCollapsed, setSidebarCollapsed] = React.useState(
+ initialSidebarCollapsed,
+ );
const activeResource = resources.find((r) => r.id === activeResourceId);
const pageTitle = activeResource?.name ?? "Resources";
const toggleSidebarCollapsed = React.useCallback(() => {
@@ -92,8 +111,9 @@ export function AdminShell({
});
}, []);
- React.useEffect(() => {
- setSidebarCollapsed(readPersistedSidebarCollapsed());
+ useIsomorphicLayoutEffect(() => {
+ const persisted = readPersistedSidebarCollapsed();
+ if (persisted !== null) setSidebarCollapsed(persisted);
}, []);
return (
diff --git a/packages/admin-next/src/server/admin-app.tsx b/packages/admin-next/src/server/admin-app.tsx
index d96da33..a49715c 100644
--- a/packages/admin-next/src/server/admin-app.tsx
+++ b/packages/admin-next/src/server/admin-app.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { cookies } from "next/headers";
import type {
ActionType,
DetailResponse,
@@ -11,6 +12,8 @@ import { AdminShell } from "../components/admin-shell.js";
import type { AdminActions } from "./actions.js";
import { clientFor, type ResolvedAdminConfig } from "./config.js";
+const SIDEBAR_COLLAPSED_COOKIE_KEY = "ag_sidebar_collapsed";
+
export interface AdminAppProps {
config: ResolvedAdminConfig;
/** Server actions bound to the same config (from a "use server" module). */
@@ -57,6 +60,8 @@ export async function AdminApp({
}: AdminAppProps): Promise
{
const { slug } = await params;
const sp = (await searchParams) ?? {};
+ const initialSidebarCollapsed =
+ (await cookies()).get(SIDEBAR_COLLAPSED_COOKIE_KEY)?.value === "true";
const client = await clientFor(config);
let resources: ResourceInfo[] = [];
@@ -109,6 +114,7 @@ export async function AdminApp({
error={error}
title={title}
headerActions={headerActions}
+ initialSidebarCollapsed={initialSidebarCollapsed}
/>
);
}
From ae0d9ae578bf579340ab45c761655da1af820945 Mon Sep 17 00:00:00 2001
From: sirily11 <32106111+sirily11@users.noreply.github.com>
Date: Tue, 7 Jul 2026 05:11:22 +0800
Subject: [PATCH 6/6] fix: prevent sidebar flicker on navigation
---
.../src/components/admin-shell.test.tsx | 8 ++++
.../admin-next/src/components/admin-shell.tsx | 12 +----
.../src/components/resource-view.test.tsx | 44 ++++++++++++++++++-
.../src/components/resource-view.tsx | 3 ++
4 files changed, 55 insertions(+), 12 deletions(-)
diff --git a/packages/admin-next/src/components/admin-shell.test.tsx b/packages/admin-next/src/components/admin-shell.test.tsx
index bee6149..eb58092 100644
--- a/packages/admin-next/src/components/admin-shell.test.tsx
+++ b/packages/admin-next/src/components/admin-shell.test.tsx
@@ -52,6 +52,14 @@ describe("AdminShell", () => {
expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeTruthy();
});
+ it("prefers localStorage over the server-provided sidebar state", () => {
+ window.localStorage.setItem("ag:sidebar-collapsed", "true");
+
+ renderShell({ initialSidebarCollapsed: false });
+
+ expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeTruthy();
+ });
+
it("restores the persisted collapsed sidebar state after remount", async () => {
renderShell();
diff --git a/packages/admin-next/src/components/admin-shell.tsx b/packages/admin-next/src/components/admin-shell.tsx
index 203f566..5c9f731 100644
--- a/packages/admin-next/src/components/admin-shell.tsx
+++ b/packages/admin-next/src/components/admin-shell.tsx
@@ -41,9 +41,6 @@ export interface AdminShellProps {
const SIDEBAR_COLLAPSED_STORAGE_KEY = "ag:sidebar-collapsed";
const SIDEBAR_COLLAPSED_COOKIE_KEY = "ag_sidebar_collapsed";
-const useIsomorphicLayoutEffect =
- typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
-
function readPersistedSidebarCollapsed(): boolean | null {
if (typeof window === "undefined") return null;
try {
@@ -98,8 +95,8 @@ export function AdminShell({
headerActions,
initialSidebarCollapsed = false,
}: AdminShellProps): React.JSX.Element {
- const [sidebarCollapsed, setSidebarCollapsed] = React.useState(
- initialSidebarCollapsed,
+ const [sidebarCollapsed, setSidebarCollapsed] = React.useState(() =>
+ readPersistedSidebarCollapsed() ?? initialSidebarCollapsed,
);
const activeResource = resources.find((r) => r.id === activeResourceId);
const pageTitle = activeResource?.name ?? "Resources";
@@ -111,11 +108,6 @@ export function AdminShell({
});
}, []);
- useIsomorphicLayoutEffect(() => {
- const persisted = readPersistedSidebarCollapsed();
- if (persisted !== null) setSidebarCollapsed(persisted);
- }, []);
-
return (
vi.fn());
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: routerPush }),
+}));
+
const schema: TableSchema = {
uiType: "table",
type: "view",
@@ -18,6 +24,10 @@ const noopActions: AdminActions = {
submitAction: vi.fn(),
};
+afterEach(() => {
+ routerPush.mockReset();
+});
+
describe("ResourceView", () => {
// A form/custom resource can return a nil SupportedActions slice, which
// serializes to JSON null. Rendering must not crash on `.some` of null.
@@ -83,4 +93,34 @@ describe("ResourceView", () => {
expect(screen.getByText("+ Create")).toBeTruthy();
});
+
+ it("uses the Next router for row navigation", () => {
+ render(
+
,
+ );
+
+ fireEvent.click(screen.getByRole("row", { name: "Open row 1" }));
+
+ expect(routerPush).toHaveBeenCalledWith("/admin/posts/1");
+ });
});
diff --git a/packages/admin-next/src/components/resource-view.tsx b/packages/admin-next/src/components/resource-view.tsx
index fe9ff44..04cca10 100644
--- a/packages/admin-next/src/components/resource-view.tsx
+++ b/packages/admin-next/src/components/resource-view.tsx
@@ -2,6 +2,7 @@
import * as React from "react";
import Link from "next/link";
+import { useRouter } from "next/navigation";
import type {
ActionButton,
ActionType,
@@ -38,6 +39,7 @@ interface SheetState {
export function ResourceView(props: ResourceViewProps): React.JSX.Element {
const { resource, resourceId, schema, actions } = props;
+ const router = useRouter();
const [data, setData] = React.useState
(
props.initialData,
);
@@ -134,6 +136,7 @@ export function ResourceView(props: ResourceViewProps): React.JSX.Element {
new URL(url, "http://x").searchParams.get("after") ?? undefined;
refresh(after);
}}
+ onRowNavigate={(url) => router.push(url)}
/>
)}
>