From 859421b0ecd8141c38b2180f34b8d2ff1bff1cd5 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:03:28 +0200 Subject: [PATCH] fix(ui): read the persisted sidebar mode without a hydration mismatch (#12873) --- .../sidebar-mode-hydration.fixed.md | 1 + .../app-sidebar/app-sidebar-content.tsx | 4 ++-- .../app-sidebar-mode-store.test.ts | 19 +++++++++++++++++++ .../app-sidebar/app-sidebar-mode-store.ts | 14 ++++++++++++++ .../app-sidebar/app-sidebar-mode-toggle.tsx | 7 +++++-- 5 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 ui/changelog.d/sidebar-mode-hydration.fixed.md diff --git a/ui/changelog.d/sidebar-mode-hydration.fixed.md b/ui/changelog.d/sidebar-mode-hydration.fixed.md new file mode 100644 index 0000000000..c495c3071d --- /dev/null +++ b/ui/changelog.d/sidebar-mode-hydration.fixed.md @@ -0,0 +1 @@ +Sidebar no longer throws a React hydration error on full page loads for users who last used the chat mode diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.tsx index f157e2f7ae..e98e0cb9b3 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-content.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-content.tsx @@ -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, diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts b/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts index ba85902736..d019319ef2 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts @@ -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); + }); }); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts b/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts index fc076c0296..b9cf055319 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts @@ -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()( }, ), ); + +// 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 + ); +} diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx index 2f62956f51..6f1cc10bcf 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx @@ -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,