fix(ui): read the persisted sidebar mode without a hydration mismatch (#12873)

This commit is contained in:
Alejandro Bailo
2026-09-23 13:03:28 +02:00
committed by GitHub
parent ea36f12a01
commit 859421b0ec
5 changed files with 41 additions and 4 deletions
@@ -0,0 +1 @@
Sidebar no longer throws a React hydration error on full page loads for users who last used the chat mode
@@ -10,7 +10,7 @@ import { useRuntimeConfig } from "@/hooks/use-runtime-config";
import { isCloud } from "@/lib/shared/env";
import { useUIStore } from "@/store/ui/store";
import { useAppSidebarMode } from "./app-sidebar-mode-store";
import { useHydratedAppSidebarMode } from "./app-sidebar-mode-store";
import { AppSidebarModeToggle } from "./app-sidebar-mode-toggle";
import { LaunchScanAction } from "./launch-scan-action";
import { getNavigationConfig } from "./navigation-config";
@@ -28,7 +28,7 @@ export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
// One-time server decision per request, seeded by the root layout.
const registryEligible = useUIStore((state) => state.registryEligible);
const { apiDocsUrl, cloudBillingEnabled } = useRuntimeConfig();
const mode = useAppSidebarMode((state) => state.mode);
const mode = useHydratedAppSidebarMode();
const isCloudEnvironment = isCloud();
const sections = getNavigationConfig({
pathname,
@@ -1,8 +1,10 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import {
migrateAppSidebarState,
useAppSidebarMode,
useHydratedAppSidebarMode,
} from "./app-sidebar-mode-store";
import { APP_SIDEBAR_MODE } from "./types";
@@ -70,4 +72,21 @@ describe("app sidebar mode store", () => {
// Then
expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.CHAT);
});
it("answers browse on the first render and the persisted mode once mounted", () => {
// Given — a tenant that last used the chat; the server rendered browse.
useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT });
const renders: string[] = [];
// When
renderHook(() => {
const mode = useHydratedAppSidebarMode();
renders.push(mode);
return mode;
});
// Then — the hydrating render matches the HTML, the next one the store.
expect(renders[0]).toBe(APP_SIDEBAR_MODE.BROWSE);
expect(renders[renders.length - 1]).toBe(APP_SIDEBAR_MODE.CHAT);
});
});
@@ -1,6 +1,8 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { useStore } from "@/hooks/use-store";
import { APP_SIDEBAR_MODE, type AppSidebarMode } from "./types";
interface PersistedAppSidebarState {
@@ -52,3 +54,15 @@ export const useAppSidebarMode = create<AppSidebarModeStore>()(
},
),
);
// The persisted mode is only known in the browser: `persist` rehydrates from
// localStorage before the first client render, while the server always
// rendered `browse`. Reading the store directly makes a tenant that last used
// the chat hydrate a different sidebar than the one in the HTML (React #418).
// This read answers `browse` until mounted, then the persisted value.
export function useHydratedAppSidebarMode(): AppSidebarMode {
return (
useStore(useAppSidebarMode, (state) => state.mode) ??
APP_SIDEBAR_MODE.BROWSE
);
}
@@ -18,7 +18,10 @@ import {
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { useAppSidebarMode } from "./app-sidebar-mode-store";
import {
useAppSidebarMode,
useHydratedAppSidebarMode,
} from "./app-sidebar-mode-store";
import {
APP_SIDEBAR_MODE,
type AppSidebarMode,
@@ -49,7 +52,7 @@ export function AppSidebarModeToggle({
}: AppSidebarModeToggleProps) {
const router = useRouter();
const pathname = usePathname();
const mode = useAppSidebarMode((state) => state.mode);
const mode = useHydratedAppSidebarMode();
const setMode = useAppSidebarMode((state) => state.setMode);
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,