From e2b971e679a12de200e1713f7f66eef7eb279350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 5 Jan 2026 12:22:05 +0100 Subject: [PATCH 01/27] Fixes sidebar animating to initial state --- src/features/sidebar/data/useDiffbarOpen.ts | 60 +++++++++++++- src/features/sidebar/data/useSidebarOpen.ts | 60 +++++++++++++- .../sidebar/view/SecondarySplitHeader.tsx | 8 +- .../sidebar/view/internal/ClientSplitView.tsx | 81 ++++++++++++++++--- .../view/internal/primary/Container.tsx | 12 ++- .../view/internal/secondary/Container.tsx | 31 ++++++- .../view/internal/tertiary/RightContainer.tsx | 12 ++- 7 files changed, 238 insertions(+), 26 deletions(-) diff --git a/src/features/sidebar/data/useDiffbarOpen.ts b/src/features/sidebar/data/useDiffbarOpen.ts index 964efd8c..51f27644 100644 --- a/src/features/sidebar/data/useDiffbarOpen.ts +++ b/src/features/sidebar/data/useDiffbarOpen.ts @@ -1,5 +1,61 @@ +import { useLayoutEffect, useState } from "react" import { useSessionStorage } from "usehooks-ts" export default function useDiffbarOpen() { - return useSessionStorage("isDiffbarOpen", false, { initializeWithValue: false }) -} \ No newline at end of file + const [isDiffbarOpen, setDiffbarOpen, removeDiffbarOpen] = useSessionStorage( + "isDiffbarOpen", + false, + { initializeWithValue: false } + ) + const [shouldAnimate, setShouldAnimate] = useSessionStorage( + "isDiffbarOpenAnimateNext", + false, + { initializeWithValue: false } + ) + const [isInitialized, setIsInitialized] = useState(false) + + useLayoutEffect(() => { + if (typeof window === "undefined") { + setShouldAnimate(false) + setIsInitialized(true) + return + } + + const rawValue = window.sessionStorage.getItem("isDiffbarOpen") + if (rawValue !== null) { + try { + const parsedValue = JSON.parse(rawValue) + if (typeof parsedValue === "boolean") { + setDiffbarOpen(parsedValue) + } + } catch (error) { + // Ignore invalid storage values and keep defaults. + } + } + + setShouldAnimate(false) + setIsInitialized(true) + }, [setDiffbarOpen, setShouldAnimate]) + + const setDiffbarOpenWithTransition = (value: boolean | ((prev: boolean) => boolean)) => { + setShouldAnimate(true) + if (typeof window === "undefined") { + setDiffbarOpen(value) + return + } + + window.setTimeout(() => { + setDiffbarOpen(value) + }, 0) + } + + return [ + isDiffbarOpen, + setDiffbarOpen, + removeDiffbarOpen, + isInitialized, + shouldAnimate, + setDiffbarOpenWithTransition, + setShouldAnimate + ] as const +} diff --git a/src/features/sidebar/data/useSidebarOpen.ts b/src/features/sidebar/data/useSidebarOpen.ts index 1a8dc151..6e9f0b43 100644 --- a/src/features/sidebar/data/useSidebarOpen.ts +++ b/src/features/sidebar/data/useSidebarOpen.ts @@ -1,5 +1,61 @@ +import { useLayoutEffect, useState } from "react" import { useSessionStorage } from "usehooks-ts" export default function useSidebarOpen() { - return useSessionStorage("isSidebarOpen", true, { initializeWithValue: false }) -} \ No newline at end of file + const [isSidebarOpen, setSidebarOpen, removeSidebarOpen] = useSessionStorage( + "isSidebarOpen", + true, + { initializeWithValue: false } + ) + const [shouldAnimate, setShouldAnimate] = useSessionStorage( + "isSidebarOpenAnimateNext", + false, + { initializeWithValue: false } + ) + const [isInitialized, setIsInitialized] = useState(false) + + useLayoutEffect(() => { + if (typeof window === "undefined") { + setShouldAnimate(false) + setIsInitialized(true) + return + } + + const rawValue = window.sessionStorage.getItem("isSidebarOpen") + if (rawValue !== null) { + try { + const parsedValue = JSON.parse(rawValue) + if (typeof parsedValue === "boolean") { + setSidebarOpen(parsedValue) + } + } catch (error) { + // Ignore invalid storage values and keep defaults. + } + } + + setShouldAnimate(false) + setIsInitialized(true) + }, [setSidebarOpen, setShouldAnimate]) + + const setSidebarOpenWithTransition = (value: boolean | ((prev: boolean) => boolean)) => { + setShouldAnimate(true) + if (typeof window === "undefined") { + setSidebarOpen(value) + return + } + + window.setTimeout(() => { + setSidebarOpen(value) + }, 0) + } + + return [ + isSidebarOpen, + setSidebarOpen, + removeSidebarOpen, + isInitialized, + shouldAnimate, + setSidebarOpenWithTransition, + setShouldAnimate + ] as const +} diff --git a/src/features/sidebar/view/SecondarySplitHeader.tsx b/src/features/sidebar/view/SecondarySplitHeader.tsx index 870bae8b..3d9c2dc9 100644 --- a/src/features/sidebar/view/SecondarySplitHeader.tsx +++ b/src/features/sidebar/view/SecondarySplitHeader.tsx @@ -20,8 +20,8 @@ const SecondarySplitHeader = ({ mobileToolbar?: React.ReactNode children?: React.ReactNode }) => { - const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() - const [isDiffbarOpen, setDiffbarOpen] = useDiffbarOpen() + const [isSidebarOpen, , , , , setSidebarOpenWithTransition] = useSidebarOpen() + const [isDiffbarOpen, , , , , setDiffbarOpenWithTransition] = useDiffbarOpen() const [isMobileToolbarVisible, setMobileToolbarVisible] = useSessionStorage("isMobileToolbarVisible", true) const { specification } = useProjectSelection() return ( @@ -36,7 +36,7 @@ const SecondarySplitHeader = ({ }}> @@ -53,7 +53,7 @@ const SecondarySplitHeader = ({ {isDiffFeatureEnabled && ( )} diff --git a/src/features/sidebar/view/internal/ClientSplitView.tsx b/src/features/sidebar/view/internal/ClientSplitView.tsx index dbda81e6..93a4f927 100644 --- a/src/features/sidebar/view/internal/ClientSplitView.tsx +++ b/src/features/sidebar/view/internal/ClientSplitView.tsx @@ -21,19 +21,42 @@ const ClientSplitView = ({ children?: React.ReactNode sidebarRight?: React.ReactNode }) => { - const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() - const [isRightSidebarOpen, setRightSidebarOpen] = useDiffbarOpen() + const [ + isSidebarOpen, + setSidebarOpen, + , + isSidebarInitialized, + shouldAnimateSidebar, + setSidebarOpenWithTransition, + setShouldAnimateSidebar + ] = useSidebarOpen() + const [ + isRightSidebarOpen, + setRightSidebarOpen, + , + isDiffbarInitialized, + shouldAnimateDiffbar, + setDiffbarOpenWithTransition, + setShouldAnimateDiffbar + ] = useDiffbarOpen() const { specification } = useProjectSelection() const isSidebarTogglable = useContext(SidebarTogglableContext) const theme = useTheme() // Determine if the screen size is small or larger const isSM = useMediaQuery(theme.breakpoints.up("sm")) + const isLayoutReady = isSidebarInitialized && isDiffbarInitialized + const sidebarTransitionDuration = Math.max( + theme.transitions.duration.enteringScreen, + theme.transitions.duration.leavingScreen + ) + const diffbarTransitionDuration = sidebarTransitionDuration useEffect(() => { if (!isSidebarTogglable && !isSidebarOpen) { setSidebarOpen(true) } }, [isSidebarOpen, isSidebarTogglable, setSidebarOpen]) + // Close diff sidebar if no specification is selected useEffect(() => { @@ -41,43 +64,81 @@ const ClientSplitView = ({ setRightSidebarOpen(false) } }, [specification, isRightSidebarOpen, setRightSidebarOpen]) + + useEffect(() => { + if (!shouldAnimateSidebar) { + return + } + + const timeout = window.setTimeout(() => { + setShouldAnimateSidebar(false) + }, sidebarTransitionDuration) + + return () => window.clearTimeout(timeout) + }, [shouldAnimateSidebar, setShouldAnimateSidebar, sidebarTransitionDuration]) + + useEffect(() => { + if (!shouldAnimateDiffbar) { + return + } + + const timeout = window.setTimeout(() => { + setShouldAnimateDiffbar(false) + }, diffbarTransitionDuration) + + return () => window.clearTimeout(timeout) + }, [shouldAnimateDiffbar, setShouldAnimateDiffbar, diffbarTransitionDuration]) + useKeyboardShortcut(event => { const isActionKey = isMac() ? event.metaKey : event.ctrlKey if (isActionKey && event.key === ".") { event.preventDefault() if (isSidebarTogglable) { - setSidebarOpen(!isSidebarOpen) + setSidebarOpenWithTransition(!isSidebarOpen) } } - }, [isSidebarTogglable, setSidebarOpen]) + }, [isSidebarTogglable, setSidebarOpenWithTransition, isSidebarOpen]) useKeyboardShortcut(event => { const isActionKey = isMac() ? event.metaKey : event.ctrlKey if (isDiffFeatureEnabled && isActionKey && event.key === "k") { event.preventDefault() - setRightSidebarOpen(!isRightSidebarOpen) + setDiffbarOpenWithTransition(!isRightSidebarOpen) } - }, [isRightSidebarOpen, setRightSidebarOpen]) + }, [isRightSidebarOpen, setDiffbarOpenWithTransition]) const sidebarWidth = 320 const diffWidth = 320 return ( - + setSidebarOpen(false)} + onClose={() => setSidebarOpenWithTransition(false)} + disableTransition={!shouldAnimateSidebar} > {sidebar} - + {children} setRightSidebarOpen(false)} + onClose={() => setDiffbarOpenWithTransition(false)} + disableTransition={!shouldAnimateDiffbar} > {sidebarRight} diff --git a/src/features/sidebar/view/internal/primary/Container.tsx b/src/features/sidebar/view/internal/primary/Container.tsx index 49a5f45e..cf9d142a 100644 --- a/src/features/sidebar/view/internal/primary/Container.tsx +++ b/src/features/sidebar/view/internal/primary/Container.tsx @@ -8,11 +8,13 @@ const PrimaryContainer = ({ width, isOpen, onClose, + disableTransition, children }: { width: number isOpen: boolean onClose?: () => void + disableTransition?: boolean children?: React.ReactNode }) => { return ( @@ -22,6 +24,7 @@ const PrimaryContainer = ({ width={width} isOpen={isOpen} onClose={onClose} + disableTransition={disableTransition} keepMounted={true} sx={{ display: { xs: "block", sm: "none" } }} > @@ -31,6 +34,7 @@ const PrimaryContainer = ({ variant="persistent" width={width} isOpen={isOpen} + disableTransition={disableTransition} keepMounted={false} sx={{ display: { xs: "none", sm: "block" } }} > @@ -47,6 +51,7 @@ const InnerPrimaryContainer = ({ width, isOpen, onClose, + disableTransition, keepMounted, sx, children @@ -55,6 +60,7 @@ const InnerPrimaryContainer = ({ width: number isOpen: boolean onClose?: () => void + disableTransition?: boolean keepMounted?: boolean sx: SxProps, children?: React.ReactNode @@ -66,6 +72,7 @@ const InnerPrimaryContainer = ({ anchor="left" open={isOpen} onClose={onClose} + transitionDuration={disableTransition ? 0 : undefined} ModalProps={{ keepMounted: keepMounted || false }} @@ -77,11 +84,12 @@ const InnerPrimaryContainer = ({ width: width, boxSizing: "border-box", borderRight: 0, - background: theme.palette.background.default + background: theme.palette.background.default, + ...(disableTransition ? { transition: "none" } : {}) } }} > {children} ) -} \ No newline at end of file +} diff --git a/src/features/sidebar/view/internal/secondary/Container.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx index 6a3a9ead..ec0bf5ee 100644 --- a/src/features/sidebar/view/internal/secondary/Container.tsx +++ b/src/features/sidebar/view/internal/secondary/Container.tsx @@ -10,6 +10,7 @@ const SecondaryContainer = ({ offsetDiffContent, children, isSM, + disableTransition, }: { sidebarWidth: number offsetContent: boolean @@ -17,6 +18,7 @@ const SecondaryContainer = ({ offsetDiffContent?: boolean children?: React.ReactNode, isSM: boolean, + disableTransition?: boolean, }) => { const sx = { overflow: "hidden" } return ( @@ -27,6 +29,7 @@ const SecondaryContainer = ({ isSidebarOpen={isSM ? offsetContent: false} diffWidth={isSM ? (diffWidth || 0) : 0} isDiffOpen={isSM ? (offsetDiffContent || false) : false} + disableTransition={disableTransition} sx={{ ...sx }} > {children} @@ -43,18 +46,35 @@ interface WrapperStackProps { readonly isSidebarOpen: boolean readonly diffWidth: number readonly isDiffOpen: boolean + readonly disableTransition?: boolean } const WrapperStack = styled(Stack, { - shouldForwardProp: (prop) => prop !== "isSidebarOpen" && prop !== "sidebarWidth" && prop !== "diffWidth" && prop !== "isDiffOpen" -})(({ theme, sidebarWidth, isSidebarOpen, diffWidth, isDiffOpen }) => { + shouldForwardProp: (prop) => + prop !== "isSidebarOpen" && + prop !== "sidebarWidth" && + prop !== "diffWidth" && + prop !== "isDiffOpen" && + prop !== "disableTransition" +})(({ theme, sidebarWidth, isSidebarOpen, diffWidth, isDiffOpen, disableTransition }) => { + const marginStyles = { + marginLeft: isSidebarOpen ? 0 : `-${sidebarWidth}px`, + marginRight: isDiffOpen ? 0 : `-${diffWidth}px`, + } + + if (disableTransition) { + return { + transition: "none", + ...marginStyles + } + } + return { transition: theme.transitions.create(["margin", "width"], { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.leavingScreen }), - marginLeft: isSidebarOpen ? 0 : `-${sidebarWidth}px`, - marginRight: isDiffOpen ? 0 : `-${diffWidth}px`, + ...marginStyles, ...((isSidebarOpen || isDiffOpen) && { transition: theme.transitions.create(["margin", "width"], { easing: theme.transitions.easing.easeOut, @@ -69,6 +89,7 @@ const InnerSecondaryContainer = ({ isSidebarOpen, diffWidth, isDiffOpen, + disableTransition, children, sx }: { @@ -76,6 +97,7 @@ const InnerSecondaryContainer = ({ isSidebarOpen: boolean diffWidth: number isDiffOpen: boolean + disableTransition?: boolean children: React.ReactNode sx?: SxProps }) => { @@ -87,6 +109,7 @@ const InnerSecondaryContainer = ({ isSidebarOpen={isSidebarOpen} diffWidth={diffWidth} isDiffOpen={isDiffOpen} + disableTransition={disableTransition} sx={{ ...sx, width: "100%", overflowY: "auto" }} > diff --git a/src/features/sidebar/view/internal/tertiary/RightContainer.tsx b/src/features/sidebar/view/internal/tertiary/RightContainer.tsx index f48f7adb..c1af7172 100644 --- a/src/features/sidebar/view/internal/tertiary/RightContainer.tsx +++ b/src/features/sidebar/view/internal/tertiary/RightContainer.tsx @@ -8,11 +8,13 @@ const RightContainer = ({ width, isOpen, onClose, + disableTransition, children }: { width: number isOpen: boolean onClose?: () => void + disableTransition?: boolean children?: React.ReactNode }) => { return ( @@ -22,6 +24,7 @@ const RightContainer = ({ width={width} isOpen={isOpen} onClose={onClose} + disableTransition={disableTransition} keepMounted={true} sx={{ display: { xs: "block", sm: "none" } }} > @@ -31,6 +34,7 @@ const RightContainer = ({ variant="persistent" width={width} isOpen={isOpen} + disableTransition={disableTransition} keepMounted={false} sx={{ display: { xs: "none", sm: "block" } }} > @@ -47,6 +51,7 @@ const InnerRightContainer = ({ width, isOpen, onClose, + disableTransition, keepMounted, sx, children @@ -55,6 +60,7 @@ const InnerRightContainer = ({ width: number isOpen: boolean onClose?: () => void + disableTransition?: boolean keepMounted?: boolean sx: SxProps children?: React.ReactNode @@ -66,6 +72,7 @@ const InnerRightContainer = ({ anchor="right" open={isOpen} onClose={onClose} + transitionDuration={disableTransition ? 0 : undefined} ModalProps={{ keepMounted: keepMounted || false }} @@ -77,11 +84,12 @@ const InnerRightContainer = ({ width: width, boxSizing: "border-box", borderLeft: 0, - background: theme.palette.background.default + background: theme.palette.background.default, + ...(disableTransition ? { transition: "none" } : {}) } }} > {children} ) -} \ No newline at end of file +} From c7477e04e4434723ced4d7928cbb2e3e6b34299b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 5 Jan 2026 12:36:45 +0100 Subject: [PATCH 02/27] Simplifies sidebar handling --- .../data/useCloseSidebarOnSelection.ts | 4 +- src/features/sidebar/data/useDiffbarOpen.ts | 31 ++++--- src/features/sidebar/data/useSidebarOpen.ts | 31 ++++--- .../sidebar/view/SecondarySplitHeader.tsx | 12 +-- .../sidebar/view/internal/ClientSplitView.tsx | 91 +++++-------------- 5 files changed, 73 insertions(+), 96 deletions(-) diff --git a/src/features/sidebar/data/useCloseSidebarOnSelection.ts b/src/features/sidebar/data/useCloseSidebarOnSelection.ts index bc7fa864..387cf1c3 100644 --- a/src/features/sidebar/data/useCloseSidebarOnSelection.ts +++ b/src/features/sidebar/data/useCloseSidebarOnSelection.ts @@ -7,11 +7,11 @@ import useSidebarOpen from "./useSidebarOpen" export default function useCloseSidebarOnSelection() { const theme = useTheme() const isDesktopLayout = useMediaQuery(theme.breakpoints.up("sm")) - const [, setSidebarOpen] = useSidebarOpen() + const sidebarState = useSidebarOpen() return { closeSidebarIfNeeded: () => { if (!isDesktopLayout) { - setSidebarOpen(false) + sidebarState.setOpen(false) } } } diff --git a/src/features/sidebar/data/useDiffbarOpen.ts b/src/features/sidebar/data/useDiffbarOpen.ts index 51f27644..0a8cb5fa 100644 --- a/src/features/sidebar/data/useDiffbarOpen.ts +++ b/src/features/sidebar/data/useDiffbarOpen.ts @@ -1,8 +1,10 @@ -import { useLayoutEffect, useState } from "react" +import { useLayoutEffect, useEffect, useState } from "react" import { useSessionStorage } from "usehooks-ts" -export default function useDiffbarOpen() { - const [isDiffbarOpen, setDiffbarOpen, removeDiffbarOpen] = useSessionStorage( +type Options = { clearAnimationAfterMs?: number } + +export default function useDiffbarOpen(options: Options = {}) { + const [isDiffbarOpen, setDiffbarOpen] = useSessionStorage( "isDiffbarOpen", false, { initializeWithValue: false } @@ -37,25 +39,32 @@ export default function useDiffbarOpen() { setIsInitialized(true) }, [setDiffbarOpen, setShouldAnimate]) + useEffect(() => { + if (!shouldAnimate || !options.clearAnimationAfterMs) { + return + } + const timeout = window.setTimeout(() => { + setShouldAnimate(false) + }, options.clearAnimationAfterMs) + return () => window.clearTimeout(timeout) + }, [options.clearAnimationAfterMs, setShouldAnimate, shouldAnimate]) + const setDiffbarOpenWithTransition = (value: boolean | ((prev: boolean) => boolean)) => { setShouldAnimate(true) if (typeof window === "undefined") { setDiffbarOpen(value) return } - window.setTimeout(() => { setDiffbarOpen(value) }, 0) } - return [ - isDiffbarOpen, - setDiffbarOpen, - removeDiffbarOpen, + return { + isOpen: isDiffbarOpen, isInitialized, shouldAnimate, - setDiffbarOpenWithTransition, - setShouldAnimate - ] as const + setOpen: setDiffbarOpen, + setOpenWithTransition: setDiffbarOpenWithTransition + } as const } diff --git a/src/features/sidebar/data/useSidebarOpen.ts b/src/features/sidebar/data/useSidebarOpen.ts index 6e9f0b43..8f05f29f 100644 --- a/src/features/sidebar/data/useSidebarOpen.ts +++ b/src/features/sidebar/data/useSidebarOpen.ts @@ -1,8 +1,10 @@ -import { useLayoutEffect, useState } from "react" +import { useLayoutEffect, useEffect, useState } from "react" import { useSessionStorage } from "usehooks-ts" -export default function useSidebarOpen() { - const [isSidebarOpen, setSidebarOpen, removeSidebarOpen] = useSessionStorage( +type Options = { clearAnimationAfterMs?: number } + +export default function useSidebarOpen(options: Options = {}) { + const [isSidebarOpen, setSidebarOpen] = useSessionStorage( "isSidebarOpen", true, { initializeWithValue: false } @@ -37,25 +39,32 @@ export default function useSidebarOpen() { setIsInitialized(true) }, [setSidebarOpen, setShouldAnimate]) + useEffect(() => { + if (!shouldAnimate || !options.clearAnimationAfterMs) { + return + } + const timeout = window.setTimeout(() => { + setShouldAnimate(false) + }, options.clearAnimationAfterMs) + return () => window.clearTimeout(timeout) + }, [options.clearAnimationAfterMs, setShouldAnimate, shouldAnimate]) + const setSidebarOpenWithTransition = (value: boolean | ((prev: boolean) => boolean)) => { setShouldAnimate(true) if (typeof window === "undefined") { setSidebarOpen(value) return } - window.setTimeout(() => { setSidebarOpen(value) }, 0) } - return [ - isSidebarOpen, - setSidebarOpen, - removeSidebarOpen, + return { + isOpen: isSidebarOpen, isInitialized, shouldAnimate, - setSidebarOpenWithTransition, - setShouldAnimate - ] as const + setOpen: setSidebarOpen, + setOpenWithTransition: setSidebarOpenWithTransition + } as const } diff --git a/src/features/sidebar/view/SecondarySplitHeader.tsx b/src/features/sidebar/view/SecondarySplitHeader.tsx index 3d9c2dc9..83d90167 100644 --- a/src/features/sidebar/view/SecondarySplitHeader.tsx +++ b/src/features/sidebar/view/SecondarySplitHeader.tsx @@ -20,8 +20,8 @@ const SecondarySplitHeader = ({ mobileToolbar?: React.ReactNode children?: React.ReactNode }) => { - const [isSidebarOpen, , , , , setSidebarOpenWithTransition] = useSidebarOpen() - const [isDiffbarOpen, , , , , setDiffbarOpenWithTransition] = useDiffbarOpen() + const sidebarState = useSidebarOpen() + const diffbarState = useDiffbarOpen() const [isMobileToolbarVisible, setMobileToolbarVisible] = useSessionStorage("isMobileToolbarVisible", true) const { specification } = useProjectSelection() return ( @@ -35,8 +35,8 @@ const SecondarySplitHeader = ({ margin: "auto" }}> @@ -52,8 +52,8 @@ const SecondarySplitHeader = ({ {isDiffFeatureEnabled && ( )} diff --git a/src/features/sidebar/view/internal/ClientSplitView.tsx b/src/features/sidebar/view/internal/ClientSplitView.tsx index 93a4f927..fb6d823b 100644 --- a/src/features/sidebar/view/internal/ClientSplitView.tsx +++ b/src/features/sidebar/view/internal/ClientSplitView.tsx @@ -21,91 +21,50 @@ const ClientSplitView = ({ children?: React.ReactNode sidebarRight?: React.ReactNode }) => { - const [ - isSidebarOpen, - setSidebarOpen, - , - isSidebarInitialized, - shouldAnimateSidebar, - setSidebarOpenWithTransition, - setShouldAnimateSidebar - ] = useSidebarOpen() - const [ - isRightSidebarOpen, - setRightSidebarOpen, - , - isDiffbarInitialized, - shouldAnimateDiffbar, - setDiffbarOpenWithTransition, - setShouldAnimateDiffbar - ] = useDiffbarOpen() - const { specification } = useProjectSelection() - const isSidebarTogglable = useContext(SidebarTogglableContext) const theme = useTheme() - // Determine if the screen size is small or larger - const isSM = useMediaQuery(theme.breakpoints.up("sm")) - const isLayoutReady = isSidebarInitialized && isDiffbarInitialized const sidebarTransitionDuration = Math.max( theme.transitions.duration.enteringScreen, theme.transitions.duration.leavingScreen ) const diffbarTransitionDuration = sidebarTransitionDuration + const sidebarState = useSidebarOpen({ clearAnimationAfterMs: sidebarTransitionDuration }) + const diffbarState = useDiffbarOpen({ clearAnimationAfterMs: diffbarTransitionDuration }) + const { specification } = useProjectSelection() + const isSidebarTogglable = useContext(SidebarTogglableContext) + const isSM = useMediaQuery(theme.breakpoints.up("sm")) + const isLayoutReady = sidebarState.isInitialized && diffbarState.isInitialized useEffect(() => { - if (!isSidebarTogglable && !isSidebarOpen) { - setSidebarOpen(true) + if (!isSidebarTogglable && !sidebarState.isOpen) { + sidebarState.setOpen(true) } - }, [isSidebarOpen, isSidebarTogglable, setSidebarOpen]) + }, [sidebarState.isOpen, isSidebarTogglable, sidebarState.setOpen]) // Close diff sidebar if no specification is selected useEffect(() => { - if (!specification && isRightSidebarOpen) { - setRightSidebarOpen(false) - } - }, [specification, isRightSidebarOpen, setRightSidebarOpen]) - - useEffect(() => { - if (!shouldAnimateSidebar) { - return - } - - const timeout = window.setTimeout(() => { - setShouldAnimateSidebar(false) - }, sidebarTransitionDuration) - - return () => window.clearTimeout(timeout) - }, [shouldAnimateSidebar, setShouldAnimateSidebar, sidebarTransitionDuration]) - - useEffect(() => { - if (!shouldAnimateDiffbar) { - return + if (!specification && diffbarState.isOpen) { + diffbarState.setOpen(false) } - - const timeout = window.setTimeout(() => { - setShouldAnimateDiffbar(false) - }, diffbarTransitionDuration) - - return () => window.clearTimeout(timeout) - }, [shouldAnimateDiffbar, setShouldAnimateDiffbar, diffbarTransitionDuration]) + }, [diffbarState.isOpen, diffbarState.setOpen, specification]) useKeyboardShortcut(event => { const isActionKey = isMac() ? event.metaKey : event.ctrlKey if (isActionKey && event.key === ".") { event.preventDefault() if (isSidebarTogglable) { - setSidebarOpenWithTransition(!isSidebarOpen) + sidebarState.setOpenWithTransition(!sidebarState.isOpen) } } - }, [isSidebarTogglable, setSidebarOpenWithTransition, isSidebarOpen]) + }, [sidebarState.isOpen, isSidebarTogglable, sidebarState.setOpenWithTransition]) useKeyboardShortcut(event => { const isActionKey = isMac() ? event.metaKey : event.ctrlKey if (isDiffFeatureEnabled && isActionKey && event.key === "k") { event.preventDefault() - setDiffbarOpenWithTransition(!isRightSidebarOpen) + diffbarState.setOpenWithTransition(!diffbarState.isOpen) } - }, [isRightSidebarOpen, setDiffbarOpenWithTransition]) + }, [diffbarState.isOpen, diffbarState.setOpenWithTransition]) const sidebarWidth = 320 const diffWidth = 320 @@ -118,27 +77,27 @@ const ClientSplitView = ({ > setSidebarOpenWithTransition(false)} - disableTransition={!shouldAnimateSidebar} + isOpen={sidebarState.isOpen} + onClose={() => sidebarState.setOpenWithTransition(false)} + disableTransition={!sidebarState.shouldAnimate} > {sidebar} {children} setDiffbarOpenWithTransition(false)} - disableTransition={!shouldAnimateDiffbar} + isOpen={diffbarState.isOpen} + onClose={() => diffbarState.setOpenWithTransition(false)} + disableTransition={!diffbarState.shouldAnimate} > {sidebarRight} From 99c1cc66d05a83c41015b46274226374bc606fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 5 Jan 2026 13:09:20 +0100 Subject: [PATCH 03/27] Fixes linting errors --- src/features/sidebar/data/useDiffbarOpen.ts | 2 +- src/features/sidebar/data/useSidebarOpen.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/sidebar/data/useDiffbarOpen.ts b/src/features/sidebar/data/useDiffbarOpen.ts index 0a8cb5fa..3a74e704 100644 --- a/src/features/sidebar/data/useDiffbarOpen.ts +++ b/src/features/sidebar/data/useDiffbarOpen.ts @@ -30,7 +30,7 @@ export default function useDiffbarOpen(options: Options = {}) { if (typeof parsedValue === "boolean") { setDiffbarOpen(parsedValue) } - } catch (error) { + } catch { // Ignore invalid storage values and keep defaults. } } diff --git a/src/features/sidebar/data/useSidebarOpen.ts b/src/features/sidebar/data/useSidebarOpen.ts index 8f05f29f..0dc01255 100644 --- a/src/features/sidebar/data/useSidebarOpen.ts +++ b/src/features/sidebar/data/useSidebarOpen.ts @@ -30,7 +30,7 @@ export default function useSidebarOpen(options: Options = {}) { if (typeof parsedValue === "boolean") { setSidebarOpen(parsedValue) } - } catch (error) { + } catch { // Ignore invalid storage values and keep defaults. } } From 4c2fe988a5da38ef76789a87c216e1c4c091115c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 5 Jan 2026 13:28:31 +0100 Subject: [PATCH 04/27] Introduces useSessionStorageState --- src/common/utils/useSessionStorageState.ts | 60 +++++++++++++++++++++ src/features/sidebar/data/useDiffbarOpen.ts | 44 ++++----------- src/features/sidebar/data/useSidebarOpen.ts | 44 ++++----------- 3 files changed, 78 insertions(+), 70 deletions(-) create mode 100644 src/common/utils/useSessionStorageState.ts diff --git a/src/common/utils/useSessionStorageState.ts b/src/common/utils/useSessionStorageState.ts new file mode 100644 index 00000000..a8c7c761 --- /dev/null +++ b/src/common/utils/useSessionStorageState.ts @@ -0,0 +1,60 @@ +import { useCallback, useSyncExternalStore } from "react" + +type SetStateAction = T | ((prev: T) => T) + +const readValue = (key: string, defaultValue: T): T => { + if (typeof window === "undefined") { + return defaultValue + } + + const raw = window.sessionStorage.getItem(key) + if (raw === null) { + return defaultValue + } + + try { + return JSON.parse(raw) as T + } catch { + return defaultValue + } +} + +const subscribeToStorage = (onStoreChange: () => void) => { + if (typeof window === "undefined") { + return () => {} + } + + const handler = () => onStoreChange() + window.addEventListener("storage", handler) + window.addEventListener("session-storage", handler) + + return () => { + window.removeEventListener("storage", handler) + window.removeEventListener("session-storage", handler) + } +} + +export default function useSessionStorageState(key: string, defaultValue: T) { + const getSnapshot = useCallback(() => readValue(key, defaultValue), [key, defaultValue]) + const getServerSnapshot = useCallback(() => defaultValue, [defaultValue]) + const value = useSyncExternalStore(subscribeToStorage, getSnapshot, getServerSnapshot) + + const setValue = useCallback( + (nextValue: SetStateAction) => { + if (typeof window === "undefined") { + return + } + + const currentValue = readValue(key, defaultValue) + const valueToStore = typeof nextValue === "function" + ? (nextValue as (prev: T) => T)(currentValue) + : nextValue + + window.sessionStorage.setItem(key, JSON.stringify(valueToStore)) + window.dispatchEvent(new Event("session-storage")) + }, + [key, defaultValue] + ) + + return [value, setValue] as const +} diff --git a/src/features/sidebar/data/useDiffbarOpen.ts b/src/features/sidebar/data/useDiffbarOpen.ts index 3a74e704..4eaadcdd 100644 --- a/src/features/sidebar/data/useDiffbarOpen.ts +++ b/src/features/sidebar/data/useDiffbarOpen.ts @@ -1,43 +1,17 @@ -import { useLayoutEffect, useEffect, useState } from "react" -import { useSessionStorage } from "usehooks-ts" +import { useEffect, useSyncExternalStore } from "react" +import useSessionStorageState from "@/common/utils/useSessionStorageState" type Options = { clearAnimationAfterMs?: number } export default function useDiffbarOpen(options: Options = {}) { - const [isDiffbarOpen, setDiffbarOpen] = useSessionStorage( - "isDiffbarOpen", - false, - { initializeWithValue: false } + const isHydrated = useSyncExternalStore( + () => () => {}, + () => true, + () => false ) - const [shouldAnimate, setShouldAnimate] = useSessionStorage( - "isDiffbarOpenAnimateNext", - false, - { initializeWithValue: false } - ) - const [isInitialized, setIsInitialized] = useState(false) - - useLayoutEffect(() => { - if (typeof window === "undefined") { - setShouldAnimate(false) - setIsInitialized(true) - return - } - - const rawValue = window.sessionStorage.getItem("isDiffbarOpen") - if (rawValue !== null) { - try { - const parsedValue = JSON.parse(rawValue) - if (typeof parsedValue === "boolean") { - setDiffbarOpen(parsedValue) - } - } catch { - // Ignore invalid storage values and keep defaults. - } - } - - setShouldAnimate(false) - setIsInitialized(true) - }, [setDiffbarOpen, setShouldAnimate]) + const [isDiffbarOpen, setDiffbarOpen] = useSessionStorageState("isDiffbarOpen", false) + const [shouldAnimate, setShouldAnimate] = useSessionStorageState("isDiffbarOpenAnimateNext", false) + const isInitialized = isHydrated useEffect(() => { if (!shouldAnimate || !options.clearAnimationAfterMs) { diff --git a/src/features/sidebar/data/useSidebarOpen.ts b/src/features/sidebar/data/useSidebarOpen.ts index 0dc01255..e12beecb 100644 --- a/src/features/sidebar/data/useSidebarOpen.ts +++ b/src/features/sidebar/data/useSidebarOpen.ts @@ -1,43 +1,17 @@ -import { useLayoutEffect, useEffect, useState } from "react" -import { useSessionStorage } from "usehooks-ts" +import { useEffect, useSyncExternalStore } from "react" +import useSessionStorageState from "@/common/utils/useSessionStorageState" type Options = { clearAnimationAfterMs?: number } export default function useSidebarOpen(options: Options = {}) { - const [isSidebarOpen, setSidebarOpen] = useSessionStorage( - "isSidebarOpen", - true, - { initializeWithValue: false } + const isHydrated = useSyncExternalStore( + () => () => {}, + () => true, + () => false ) - const [shouldAnimate, setShouldAnimate] = useSessionStorage( - "isSidebarOpenAnimateNext", - false, - { initializeWithValue: false } - ) - const [isInitialized, setIsInitialized] = useState(false) - - useLayoutEffect(() => { - if (typeof window === "undefined") { - setShouldAnimate(false) - setIsInitialized(true) - return - } - - const rawValue = window.sessionStorage.getItem("isSidebarOpen") - if (rawValue !== null) { - try { - const parsedValue = JSON.parse(rawValue) - if (typeof parsedValue === "boolean") { - setSidebarOpen(parsedValue) - } - } catch { - // Ignore invalid storage values and keep defaults. - } - } - - setShouldAnimate(false) - setIsInitialized(true) - }, [setSidebarOpen, setShouldAnimate]) + const [isSidebarOpen, setSidebarOpen] = useSessionStorageState("isSidebarOpen", true) + const [shouldAnimate, setShouldAnimate] = useSessionStorageState("isSidebarOpenAnimateNext", false) + const isInitialized = isHydrated useEffect(() => { if (!shouldAnimate || !options.clearAnimationAfterMs) { From fa3000c5b04b6521ea6266c909a83b35d39afc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 5 Jan 2026 13:33:56 +0100 Subject: [PATCH 05/27] Fixes linting warnings --- src/common/utils/useSessionStorageState.ts | 60 ------------------- src/features/sidebar/data/useDiffbarOpen.ts | 46 +++++++++++--- src/features/sidebar/data/useSidebarOpen.ts | 46 +++++++++++--- .../sidebar/view/internal/ClientSplitView.tsx | 56 ++++++++++------- 4 files changed, 108 insertions(+), 100 deletions(-) delete mode 100644 src/common/utils/useSessionStorageState.ts diff --git a/src/common/utils/useSessionStorageState.ts b/src/common/utils/useSessionStorageState.ts deleted file mode 100644 index a8c7c761..00000000 --- a/src/common/utils/useSessionStorageState.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useCallback, useSyncExternalStore } from "react" - -type SetStateAction = T | ((prev: T) => T) - -const readValue = (key: string, defaultValue: T): T => { - if (typeof window === "undefined") { - return defaultValue - } - - const raw = window.sessionStorage.getItem(key) - if (raw === null) { - return defaultValue - } - - try { - return JSON.parse(raw) as T - } catch { - return defaultValue - } -} - -const subscribeToStorage = (onStoreChange: () => void) => { - if (typeof window === "undefined") { - return () => {} - } - - const handler = () => onStoreChange() - window.addEventListener("storage", handler) - window.addEventListener("session-storage", handler) - - return () => { - window.removeEventListener("storage", handler) - window.removeEventListener("session-storage", handler) - } -} - -export default function useSessionStorageState(key: string, defaultValue: T) { - const getSnapshot = useCallback(() => readValue(key, defaultValue), [key, defaultValue]) - const getServerSnapshot = useCallback(() => defaultValue, [defaultValue]) - const value = useSyncExternalStore(subscribeToStorage, getSnapshot, getServerSnapshot) - - const setValue = useCallback( - (nextValue: SetStateAction) => { - if (typeof window === "undefined") { - return - } - - const currentValue = readValue(key, defaultValue) - const valueToStore = typeof nextValue === "function" - ? (nextValue as (prev: T) => T)(currentValue) - : nextValue - - window.sessionStorage.setItem(key, JSON.stringify(valueToStore)) - window.dispatchEvent(new Event("session-storage")) - }, - [key, defaultValue] - ) - - return [value, setValue] as const -} diff --git a/src/features/sidebar/data/useDiffbarOpen.ts b/src/features/sidebar/data/useDiffbarOpen.ts index 4eaadcdd..554e763a 100644 --- a/src/features/sidebar/data/useDiffbarOpen.ts +++ b/src/features/sidebar/data/useDiffbarOpen.ts @@ -1,17 +1,45 @@ -import { useEffect, useSyncExternalStore } from "react" -import useSessionStorageState from "@/common/utils/useSessionStorageState" +import { useLayoutEffect, useEffect, useState } from "react" +import { useSessionStorage } from "usehooks-ts" type Options = { clearAnimationAfterMs?: number } export default function useDiffbarOpen(options: Options = {}) { - const isHydrated = useSyncExternalStore( - () => () => {}, - () => true, - () => false + const [isDiffbarOpen, setDiffbarOpen] = useSessionStorage( + "isDiffbarOpen", + false, + { initializeWithValue: false } ) - const [isDiffbarOpen, setDiffbarOpen] = useSessionStorageState("isDiffbarOpen", false) - const [shouldAnimate, setShouldAnimate] = useSessionStorageState("isDiffbarOpenAnimateNext", false) - const isInitialized = isHydrated + const [shouldAnimate, setShouldAnimate] = useSessionStorage( + "isDiffbarOpenAnimateNext", + false, + { initializeWithValue: false } + ) + const [isInitialized, setIsInitialized] = useState(false) + + useLayoutEffect(() => { + if (typeof window === "undefined") { + return + } + + const rawValue = window.sessionStorage.getItem("isDiffbarOpen") + if (rawValue !== null) { + try { + const parsedValue = JSON.parse(rawValue) + if (typeof parsedValue === "boolean") { + setDiffbarOpen(parsedValue) + } + } catch { + // Ignore invalid storage values and keep defaults. + } + } + + const timeout = window.setTimeout(() => { + setShouldAnimate(false) + setIsInitialized(true) + }, 0) + + return () => window.clearTimeout(timeout) + }, [setDiffbarOpen, setShouldAnimate]) useEffect(() => { if (!shouldAnimate || !options.clearAnimationAfterMs) { diff --git a/src/features/sidebar/data/useSidebarOpen.ts b/src/features/sidebar/data/useSidebarOpen.ts index e12beecb..9f873759 100644 --- a/src/features/sidebar/data/useSidebarOpen.ts +++ b/src/features/sidebar/data/useSidebarOpen.ts @@ -1,17 +1,45 @@ -import { useEffect, useSyncExternalStore } from "react" -import useSessionStorageState from "@/common/utils/useSessionStorageState" +import { useLayoutEffect, useEffect, useState } from "react" +import { useSessionStorage } from "usehooks-ts" type Options = { clearAnimationAfterMs?: number } export default function useSidebarOpen(options: Options = {}) { - const isHydrated = useSyncExternalStore( - () => () => {}, - () => true, - () => false + const [isSidebarOpen, setSidebarOpen] = useSessionStorage( + "isSidebarOpen", + true, + { initializeWithValue: false } ) - const [isSidebarOpen, setSidebarOpen] = useSessionStorageState("isSidebarOpen", true) - const [shouldAnimate, setShouldAnimate] = useSessionStorageState("isSidebarOpenAnimateNext", false) - const isInitialized = isHydrated + const [shouldAnimate, setShouldAnimate] = useSessionStorage( + "isSidebarOpenAnimateNext", + false, + { initializeWithValue: false } + ) + const [isInitialized, setIsInitialized] = useState(false) + + useLayoutEffect(() => { + if (typeof window === "undefined") { + return + } + + const rawValue = window.sessionStorage.getItem("isSidebarOpen") + if (rawValue !== null) { + try { + const parsedValue = JSON.parse(rawValue) + if (typeof parsedValue === "boolean") { + setSidebarOpen(parsedValue) + } + } catch { + // Ignore invalid storage values and keep defaults. + } + } + + const timeout = window.setTimeout(() => { + setShouldAnimate(false) + setIsInitialized(true) + }, 0) + + return () => window.clearTimeout(timeout) + }, [setSidebarOpen, setShouldAnimate]) useEffect(() => { if (!shouldAnimate || !options.clearAnimationAfterMs) { diff --git a/src/features/sidebar/view/internal/ClientSplitView.tsx b/src/features/sidebar/view/internal/ClientSplitView.tsx index fb6d823b..6f370208 100644 --- a/src/features/sidebar/view/internal/ClientSplitView.tsx +++ b/src/features/sidebar/view/internal/ClientSplitView.tsx @@ -27,44 +27,56 @@ const ClientSplitView = ({ theme.transitions.duration.leavingScreen ) const diffbarTransitionDuration = sidebarTransitionDuration - const sidebarState = useSidebarOpen({ clearAnimationAfterMs: sidebarTransitionDuration }) - const diffbarState = useDiffbarOpen({ clearAnimationAfterMs: diffbarTransitionDuration }) + const { + isOpen: isSidebarOpen, + isInitialized: isSidebarInitialized, + shouldAnimate: shouldAnimateSidebar, + setOpen: setSidebarOpen, + setOpenWithTransition: setSidebarOpenWithTransition + } = useSidebarOpen({ clearAnimationAfterMs: sidebarTransitionDuration }) + const { + isOpen: isDiffbarOpen, + isInitialized: isDiffbarInitialized, + shouldAnimate: shouldAnimateDiffbar, + setOpen: setDiffbarOpen, + setOpenWithTransition: setDiffbarOpenWithTransition + } = useDiffbarOpen({ clearAnimationAfterMs: diffbarTransitionDuration }) const { specification } = useProjectSelection() const isSidebarTogglable = useContext(SidebarTogglableContext) const isSM = useMediaQuery(theme.breakpoints.up("sm")) - const isLayoutReady = sidebarState.isInitialized && diffbarState.isInitialized + const isLayoutReady = isSidebarInitialized && isDiffbarInitialized useEffect(() => { - if (!isSidebarTogglable && !sidebarState.isOpen) { - sidebarState.setOpen(true) + if (!isSidebarTogglable && !isSidebarOpen) { + setSidebarOpen(true) } - }, [sidebarState.isOpen, isSidebarTogglable, sidebarState.setOpen]) + }, [isSidebarOpen, isSidebarTogglable, setSidebarOpen]) // Close diff sidebar if no specification is selected useEffect(() => { - if (!specification && diffbarState.isOpen) { - diffbarState.setOpen(false) + if (!specification && isDiffbarOpen) { + setDiffbarOpen(false) } - }, [diffbarState.isOpen, diffbarState.setOpen, specification]) + }, [isDiffbarOpen, setDiffbarOpen, specification]) useKeyboardShortcut(event => { const isActionKey = isMac() ? event.metaKey : event.ctrlKey if (isActionKey && event.key === ".") { event.preventDefault() if (isSidebarTogglable) { - sidebarState.setOpenWithTransition(!sidebarState.isOpen) + setSidebarOpenWithTransition(!isSidebarOpen) } } - }, [sidebarState.isOpen, isSidebarTogglable, sidebarState.setOpenWithTransition]) + }, [isSidebarOpen, isSidebarTogglable, setSidebarOpenWithTransition]) useKeyboardShortcut(event => { const isActionKey = isMac() ? event.metaKey : event.ctrlKey if (isDiffFeatureEnabled && isActionKey && event.key === "k") { event.preventDefault() - diffbarState.setOpenWithTransition(!diffbarState.isOpen) + setDiffbarOpenWithTransition(!isDiffbarOpen) } - }, [diffbarState.isOpen, diffbarState.setOpenWithTransition]) + }, [isDiffbarOpen, setDiffbarOpenWithTransition]) const sidebarWidth = 320 const diffWidth = 320 @@ -77,27 +89,27 @@ const ClientSplitView = ({ > sidebarState.setOpenWithTransition(false)} - disableTransition={!sidebarState.shouldAnimate} + isOpen={isSidebarOpen} + onClose={() => setSidebarOpenWithTransition(false)} + disableTransition={!shouldAnimateSidebar} > {sidebar} {children} diffbarState.setOpenWithTransition(false)} - disableTransition={!diffbarState.shouldAnimate} + isOpen={isDiffbarOpen} + onClose={() => setDiffbarOpenWithTransition(false)} + disableTransition={!shouldAnimateDiffbar} > {sidebarRight} From 9a8f6719721b620aa8d07c632fc51b83ab47451b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=2E=20St=C3=B8vring?= Date: Mon, 5 Jan 2026 13:45:57 +0100 Subject: [PATCH 06/27] Update src/features/sidebar/data/useCloseSidebarOnSelection.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/features/sidebar/data/useCloseSidebarOnSelection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/sidebar/data/useCloseSidebarOnSelection.ts b/src/features/sidebar/data/useCloseSidebarOnSelection.ts index 387cf1c3..5cb50590 100644 --- a/src/features/sidebar/data/useCloseSidebarOnSelection.ts +++ b/src/features/sidebar/data/useCloseSidebarOnSelection.ts @@ -11,7 +11,7 @@ export default function useCloseSidebarOnSelection() { return { closeSidebarIfNeeded: () => { if (!isDesktopLayout) { - sidebarState.setOpen(false) + sidebarState.setOpenWithTransition(false) } } } From 3b25bd153a740fbb4acfe98d15782f952535d0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=2E=20St=C3=B8vring?= Date: Mon, 5 Jan 2026 13:46:40 +0100 Subject: [PATCH 07/27] Update src/features/sidebar/view/internal/secondary/Container.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/features/sidebar/view/internal/secondary/Container.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/sidebar/view/internal/secondary/Container.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx index ec0bf5ee..73ae7756 100644 --- a/src/features/sidebar/view/internal/secondary/Container.tsx +++ b/src/features/sidebar/view/internal/secondary/Container.tsx @@ -26,7 +26,7 @@ const SecondaryContainer = ({ Date: Mon, 5 Jan 2026 13:47:23 +0100 Subject: [PATCH 08/27] Update src/features/sidebar/view/internal/ClientSplitView.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/features/sidebar/view/internal/ClientSplitView.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/features/sidebar/view/internal/ClientSplitView.tsx b/src/features/sidebar/view/internal/ClientSplitView.tsx index 6f370208..2f248e54 100644 --- a/src/features/sidebar/view/internal/ClientSplitView.tsx +++ b/src/features/sidebar/view/internal/ClientSplitView.tsx @@ -51,8 +51,6 @@ const ClientSplitView = ({ setSidebarOpen(true) } }, [isSidebarOpen, isSidebarTogglable, setSidebarOpen]) - - // Close diff sidebar if no specification is selected useEffect(() => { if (!specification && isDiffbarOpen) { From 4c7fee0872016dee39a96832ec578c81362b3ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=2E=20St=C3=B8vring?= Date: Mon, 5 Jan 2026 13:47:39 +0100 Subject: [PATCH 09/27] Update src/features/sidebar/view/internal/secondary/Container.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/features/sidebar/view/internal/secondary/Container.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/features/sidebar/view/internal/secondary/Container.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx index 73ae7756..cfc680f4 100644 --- a/src/features/sidebar/view/internal/secondary/Container.tsx +++ b/src/features/sidebar/view/internal/secondary/Container.tsx @@ -111,7 +111,6 @@ const InnerSecondaryContainer = ({ isDiffOpen={isDiffOpen} disableTransition={disableTransition} sx={{ ...sx, width: "100%", overflowY: "auto" }} - > {children} From 506a5e965d0231ac0162ef751d6728460f6caf60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 5 Jan 2026 13:55:04 +0100 Subject: [PATCH 10/27] Uses cancelAnimationFrame() --- src/features/sidebar/data/useDiffbarOpen.ts | 26 ++++++++++++++++----- src/features/sidebar/data/useSidebarOpen.ts | 26 ++++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/features/sidebar/data/useDiffbarOpen.ts b/src/features/sidebar/data/useDiffbarOpen.ts index 554e763a..d7c9b2eb 100644 --- a/src/features/sidebar/data/useDiffbarOpen.ts +++ b/src/features/sidebar/data/useDiffbarOpen.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect, useEffect, useState } from "react" +import { useLayoutEffect, useEffect, useState, useRef } from "react" import { useSessionStorage } from "usehooks-ts" type Options = { clearAnimationAfterMs?: number } @@ -15,6 +15,7 @@ export default function useDiffbarOpen(options: Options = {}) { { initializeWithValue: false } ) const [isInitialized, setIsInitialized] = useState(false) + const rafRef = useRef(null) useLayoutEffect(() => { if (typeof window === "undefined") { @@ -33,12 +34,12 @@ export default function useDiffbarOpen(options: Options = {}) { } } - const timeout = window.setTimeout(() => { + const raf = window.requestAnimationFrame(() => { setShouldAnimate(false) setIsInitialized(true) - }, 0) + }) - return () => window.clearTimeout(timeout) + return () => window.cancelAnimationFrame(raf) }, [setDiffbarOpen, setShouldAnimate]) useEffect(() => { @@ -57,11 +58,24 @@ export default function useDiffbarOpen(options: Options = {}) { setDiffbarOpen(value) return } - window.setTimeout(() => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + } + rafRef.current = window.requestAnimationFrame(() => { setDiffbarOpen(value) - }, 0) + rafRef.current = null + }) } + useEffect(() => { + return () => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + } + }, []) + return { isOpen: isDiffbarOpen, isInitialized, diff --git a/src/features/sidebar/data/useSidebarOpen.ts b/src/features/sidebar/data/useSidebarOpen.ts index 9f873759..4933a800 100644 --- a/src/features/sidebar/data/useSidebarOpen.ts +++ b/src/features/sidebar/data/useSidebarOpen.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect, useEffect, useState } from "react" +import { useLayoutEffect, useEffect, useState, useRef } from "react" import { useSessionStorage } from "usehooks-ts" type Options = { clearAnimationAfterMs?: number } @@ -15,6 +15,7 @@ export default function useSidebarOpen(options: Options = {}) { { initializeWithValue: false } ) const [isInitialized, setIsInitialized] = useState(false) + const rafRef = useRef(null) useLayoutEffect(() => { if (typeof window === "undefined") { @@ -33,12 +34,12 @@ export default function useSidebarOpen(options: Options = {}) { } } - const timeout = window.setTimeout(() => { + const raf = window.requestAnimationFrame(() => { setShouldAnimate(false) setIsInitialized(true) - }, 0) + }) - return () => window.clearTimeout(timeout) + return () => window.cancelAnimationFrame(raf) }, [setSidebarOpen, setShouldAnimate]) useEffect(() => { @@ -57,11 +58,24 @@ export default function useSidebarOpen(options: Options = {}) { setSidebarOpen(value) return } - window.setTimeout(() => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + } + rafRef.current = window.requestAnimationFrame(() => { setSidebarOpen(value) - }, 0) + rafRef.current = null + }) } + useEffect(() => { + return () => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + } + }, []) + return { isOpen: isSidebarOpen, isInitialized, From 8473e41dcbda6ede0e36bdbac9c6e747bc5b3333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 5 Jan 2026 14:03:17 +0100 Subject: [PATCH 11/27] Generalizes useSidebarOpen and useDiffbarOpen --- src/features/sidebar/data/useDiffbarOpen.ts | 83 +------------------ src/features/sidebar/data/usePanelOpen.ts | 88 +++++++++++++++++++++ src/features/sidebar/data/useSidebarOpen.ts | 83 +------------------ 3 files changed, 92 insertions(+), 162 deletions(-) create mode 100644 src/features/sidebar/data/usePanelOpen.ts diff --git a/src/features/sidebar/data/useDiffbarOpen.ts b/src/features/sidebar/data/useDiffbarOpen.ts index d7c9b2eb..0f7c329e 100644 --- a/src/features/sidebar/data/useDiffbarOpen.ts +++ b/src/features/sidebar/data/useDiffbarOpen.ts @@ -1,86 +1,7 @@ -import { useLayoutEffect, useEffect, useState, useRef } from "react" -import { useSessionStorage } from "usehooks-ts" +import usePanelOpen from "./usePanelOpen" type Options = { clearAnimationAfterMs?: number } export default function useDiffbarOpen(options: Options = {}) { - const [isDiffbarOpen, setDiffbarOpen] = useSessionStorage( - "isDiffbarOpen", - false, - { initializeWithValue: false } - ) - const [shouldAnimate, setShouldAnimate] = useSessionStorage( - "isDiffbarOpenAnimateNext", - false, - { initializeWithValue: false } - ) - const [isInitialized, setIsInitialized] = useState(false) - const rafRef = useRef(null) - - useLayoutEffect(() => { - if (typeof window === "undefined") { - return - } - - const rawValue = window.sessionStorage.getItem("isDiffbarOpen") - if (rawValue !== null) { - try { - const parsedValue = JSON.parse(rawValue) - if (typeof parsedValue === "boolean") { - setDiffbarOpen(parsedValue) - } - } catch { - // Ignore invalid storage values and keep defaults. - } - } - - const raf = window.requestAnimationFrame(() => { - setShouldAnimate(false) - setIsInitialized(true) - }) - - return () => window.cancelAnimationFrame(raf) - }, [setDiffbarOpen, setShouldAnimate]) - - useEffect(() => { - if (!shouldAnimate || !options.clearAnimationAfterMs) { - return - } - const timeout = window.setTimeout(() => { - setShouldAnimate(false) - }, options.clearAnimationAfterMs) - return () => window.clearTimeout(timeout) - }, [options.clearAnimationAfterMs, setShouldAnimate, shouldAnimate]) - - const setDiffbarOpenWithTransition = (value: boolean | ((prev: boolean) => boolean)) => { - setShouldAnimate(true) - if (typeof window === "undefined") { - setDiffbarOpen(value) - return - } - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current) - } - rafRef.current = window.requestAnimationFrame(() => { - setDiffbarOpen(value) - rafRef.current = null - }) - } - - useEffect(() => { - return () => { - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }, []) - - return { - isOpen: isDiffbarOpen, - isInitialized, - shouldAnimate, - setOpen: setDiffbarOpen, - setOpenWithTransition: setDiffbarOpenWithTransition - } as const + return usePanelOpen("isDiffbarOpen", false, options) } diff --git a/src/features/sidebar/data/usePanelOpen.ts b/src/features/sidebar/data/usePanelOpen.ts new file mode 100644 index 00000000..73712632 --- /dev/null +++ b/src/features/sidebar/data/usePanelOpen.ts @@ -0,0 +1,88 @@ +import { useLayoutEffect, useEffect, useState, useRef } from "react" +import { useSessionStorage } from "usehooks-ts" + +type Options = { clearAnimationAfterMs?: number } + +export default function usePanelOpen( + key: string, + defaultValue: boolean, + options: Options = {} +) { + const [isOpen, setOpen] = useSessionStorage(key, defaultValue, { + initializeWithValue: false + }) + const [shouldAnimate, setShouldAnimate] = useSessionStorage( + `${key}AnimateNext`, + false, + { initializeWithValue: false } + ) + const [isInitialized, setIsInitialized] = useState(false) + const rafRef = useRef(null) + + useLayoutEffect(() => { + if (typeof window === "undefined") { + return + } + + const rawValue = window.sessionStorage.getItem(key) + if (rawValue !== null) { + try { + const parsedValue = JSON.parse(rawValue) + if (typeof parsedValue === "boolean") { + setOpen(parsedValue) + } + } catch { + // Ignore invalid storage values and keep defaults. + } + } + + const raf = window.requestAnimationFrame(() => { + setShouldAnimate(false) + setIsInitialized(true) + }) + + return () => window.cancelAnimationFrame(raf) + }, [key, setOpen, setShouldAnimate]) + + useEffect(() => { + if (!shouldAnimate || !options.clearAnimationAfterMs) { + return + } + const timeout = window.setTimeout(() => { + setShouldAnimate(false) + }, options.clearAnimationAfterMs) + return () => window.clearTimeout(timeout) + }, [options.clearAnimationAfterMs, setShouldAnimate, shouldAnimate]) + + const setOpenWithTransition = (value: boolean | ((prev: boolean) => boolean)) => { + setShouldAnimate(true) + if (typeof window === "undefined") { + setOpen(value) + return + } + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + } + rafRef.current = window.requestAnimationFrame(() => { + setOpen(value) + rafRef.current = null + }) + } + + useEffect(() => { + return () => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + } + }, []) + + return { + isOpen, + isInitialized, + shouldAnimate, + setOpen, + setOpenWithTransition + } as const +} diff --git a/src/features/sidebar/data/useSidebarOpen.ts b/src/features/sidebar/data/useSidebarOpen.ts index 4933a800..44c065c8 100644 --- a/src/features/sidebar/data/useSidebarOpen.ts +++ b/src/features/sidebar/data/useSidebarOpen.ts @@ -1,86 +1,7 @@ -import { useLayoutEffect, useEffect, useState, useRef } from "react" -import { useSessionStorage } from "usehooks-ts" +import usePanelOpen from "./usePanelOpen" type Options = { clearAnimationAfterMs?: number } export default function useSidebarOpen(options: Options = {}) { - const [isSidebarOpen, setSidebarOpen] = useSessionStorage( - "isSidebarOpen", - true, - { initializeWithValue: false } - ) - const [shouldAnimate, setShouldAnimate] = useSessionStorage( - "isSidebarOpenAnimateNext", - false, - { initializeWithValue: false } - ) - const [isInitialized, setIsInitialized] = useState(false) - const rafRef = useRef(null) - - useLayoutEffect(() => { - if (typeof window === "undefined") { - return - } - - const rawValue = window.sessionStorage.getItem("isSidebarOpen") - if (rawValue !== null) { - try { - const parsedValue = JSON.parse(rawValue) - if (typeof parsedValue === "boolean") { - setSidebarOpen(parsedValue) - } - } catch { - // Ignore invalid storage values and keep defaults. - } - } - - const raf = window.requestAnimationFrame(() => { - setShouldAnimate(false) - setIsInitialized(true) - }) - - return () => window.cancelAnimationFrame(raf) - }, [setSidebarOpen, setShouldAnimate]) - - useEffect(() => { - if (!shouldAnimate || !options.clearAnimationAfterMs) { - return - } - const timeout = window.setTimeout(() => { - setShouldAnimate(false) - }, options.clearAnimationAfterMs) - return () => window.clearTimeout(timeout) - }, [options.clearAnimationAfterMs, setShouldAnimate, shouldAnimate]) - - const setSidebarOpenWithTransition = (value: boolean | ((prev: boolean) => boolean)) => { - setShouldAnimate(true) - if (typeof window === "undefined") { - setSidebarOpen(value) - return - } - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current) - } - rafRef.current = window.requestAnimationFrame(() => { - setSidebarOpen(value) - rafRef.current = null - }) - } - - useEffect(() => { - return () => { - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }, []) - - return { - isOpen: isSidebarOpen, - isInitialized, - shouldAnimate, - setOpen: setSidebarOpen, - setOpenWithTransition: setSidebarOpenWithTransition - } as const + return usePanelOpen("isSidebarOpen", true, options) } From 8650e4f26a83de8cdb33f22f9d3610be10a82a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 08:46:37 +0100 Subject: [PATCH 12/27] Clean up --- src/features/sidebar/view/SecondarySplitHeader.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/features/sidebar/view/SecondarySplitHeader.tsx b/src/features/sidebar/view/SecondarySplitHeader.tsx index 83d90167..74111789 100644 --- a/src/features/sidebar/view/SecondarySplitHeader.tsx +++ b/src/features/sidebar/view/SecondarySplitHeader.tsx @@ -20,8 +20,8 @@ const SecondarySplitHeader = ({ mobileToolbar?: React.ReactNode children?: React.ReactNode }) => { - const sidebarState = useSidebarOpen() - const diffbarState = useDiffbarOpen() + const { isOpen: isSidebarOpen, setOpenWithTransition: setSidebarOpen } = useSidebarOpen() + const { isOpen: isDiffbarOpen, setOpenWithTransition: setDiffbarOpen } = useDiffbarOpen() const [isMobileToolbarVisible, setMobileToolbarVisible] = useSessionStorage("isMobileToolbarVisible", true) const { specification } = useProjectSelection() return ( @@ -35,8 +35,8 @@ const SecondarySplitHeader = ({ margin: "auto" }}> @@ -52,8 +52,8 @@ const SecondarySplitHeader = ({ {isDiffFeatureEnabled && ( )} From 4f7154bdec10d553228bd706124d5b60004b58de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 08:47:23 +0100 Subject: [PATCH 13/27] Fixes incorrect state --- src/features/sidebar/view/SecondarySplitHeader.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/sidebar/view/SecondarySplitHeader.tsx b/src/features/sidebar/view/SecondarySplitHeader.tsx index 74111789..bc6bd9b2 100644 --- a/src/features/sidebar/view/SecondarySplitHeader.tsx +++ b/src/features/sidebar/view/SecondarySplitHeader.tsx @@ -52,8 +52,8 @@ const SecondarySplitHeader = ({ {isDiffFeatureEnabled && ( )} From 0f35cbcf87ae423e01c38d82a1fdd2002a96a2cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 08:48:28 +0100 Subject: [PATCH 14/27] Renames sidebarTransitionDuration --- src/features/sidebar/view/internal/ClientSplitView.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/features/sidebar/view/internal/ClientSplitView.tsx b/src/features/sidebar/view/internal/ClientSplitView.tsx index 2f248e54..7400e18b 100644 --- a/src/features/sidebar/view/internal/ClientSplitView.tsx +++ b/src/features/sidebar/view/internal/ClientSplitView.tsx @@ -22,25 +22,24 @@ const ClientSplitView = ({ sidebarRight?: React.ReactNode }) => { const theme = useTheme() - const sidebarTransitionDuration = Math.max( + const panelTransitionDuration = Math.max( theme.transitions.duration.enteringScreen, theme.transitions.duration.leavingScreen ) - const diffbarTransitionDuration = sidebarTransitionDuration const { isOpen: isSidebarOpen, isInitialized: isSidebarInitialized, shouldAnimate: shouldAnimateSidebar, setOpen: setSidebarOpen, setOpenWithTransition: setSidebarOpenWithTransition - } = useSidebarOpen({ clearAnimationAfterMs: sidebarTransitionDuration }) + } = useSidebarOpen({ clearAnimationAfterMs: panelTransitionDuration }) const { isOpen: isDiffbarOpen, isInitialized: isDiffbarInitialized, shouldAnimate: shouldAnimateDiffbar, setOpen: setDiffbarOpen, setOpenWithTransition: setDiffbarOpenWithTransition - } = useDiffbarOpen({ clearAnimationAfterMs: diffbarTransitionDuration }) + } = useDiffbarOpen({ clearAnimationAfterMs: panelTransitionDuration }) const { specification } = useProjectSelection() const isSidebarTogglable = useContext(SidebarTogglableContext) const isSM = useMediaQuery(theme.breakpoints.up("sm")) From 7e1b0b5f45d9fffc7364191c46467aeedec1515f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 09:44:17 +0100 Subject: [PATCH 15/27] Simplifies transition state handling --- .../data/useCloseSidebarOnSelection.ts | 6 +- src/features/sidebar/data/useDiffbarOpen.ts | 10 +- src/features/sidebar/data/usePanelOpen.ts | 88 -------------- src/features/sidebar/data/useSidebarOpen.ts | 10 +- .../sidebar/view/SecondarySplitHeader.tsx | 6 +- .../sidebar/view/internal/ClientSplitView.tsx | 114 ++++++++---------- .../ClientSplitViewTransitionContext.tsx | 14 +++ .../view/internal/primary/Container.tsx | 13 +- .../view/internal/secondary/Container.tsx | 24 ++-- .../view/internal/tertiary/RightContainer.tsx | 13 +- .../useClientSplitViewTransitionEnabled.ts | 26 ++++ 11 files changed, 126 insertions(+), 198 deletions(-) delete mode 100644 src/features/sidebar/data/usePanelOpen.ts create mode 100644 src/features/sidebar/view/internal/ClientSplitViewTransitionContext.tsx create mode 100644 src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts diff --git a/src/features/sidebar/data/useCloseSidebarOnSelection.ts b/src/features/sidebar/data/useCloseSidebarOnSelection.ts index 5cb50590..c3779952 100644 --- a/src/features/sidebar/data/useCloseSidebarOnSelection.ts +++ b/src/features/sidebar/data/useCloseSidebarOnSelection.ts @@ -7,12 +7,12 @@ import useSidebarOpen from "./useSidebarOpen" export default function useCloseSidebarOnSelection() { const theme = useTheme() const isDesktopLayout = useMediaQuery(theme.breakpoints.up("sm")) - const sidebarState = useSidebarOpen() + const [, setSidebarOpen] = useSidebarOpen() return { closeSidebarIfNeeded: () => { if (!isDesktopLayout) { - sidebarState.setOpenWithTransition(false) + setSidebarOpen(false) } } } -} +} \ No newline at end of file diff --git a/src/features/sidebar/data/useDiffbarOpen.ts b/src/features/sidebar/data/useDiffbarOpen.ts index 0f7c329e..964efd8c 100644 --- a/src/features/sidebar/data/useDiffbarOpen.ts +++ b/src/features/sidebar/data/useDiffbarOpen.ts @@ -1,7 +1,5 @@ -import usePanelOpen from "./usePanelOpen" +import { useSessionStorage } from "usehooks-ts" -type Options = { clearAnimationAfterMs?: number } - -export default function useDiffbarOpen(options: Options = {}) { - return usePanelOpen("isDiffbarOpen", false, options) -} +export default function useDiffbarOpen() { + return useSessionStorage("isDiffbarOpen", false, { initializeWithValue: false }) +} \ No newline at end of file diff --git a/src/features/sidebar/data/usePanelOpen.ts b/src/features/sidebar/data/usePanelOpen.ts deleted file mode 100644 index 73712632..00000000 --- a/src/features/sidebar/data/usePanelOpen.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useLayoutEffect, useEffect, useState, useRef } from "react" -import { useSessionStorage } from "usehooks-ts" - -type Options = { clearAnimationAfterMs?: number } - -export default function usePanelOpen( - key: string, - defaultValue: boolean, - options: Options = {} -) { - const [isOpen, setOpen] = useSessionStorage(key, defaultValue, { - initializeWithValue: false - }) - const [shouldAnimate, setShouldAnimate] = useSessionStorage( - `${key}AnimateNext`, - false, - { initializeWithValue: false } - ) - const [isInitialized, setIsInitialized] = useState(false) - const rafRef = useRef(null) - - useLayoutEffect(() => { - if (typeof window === "undefined") { - return - } - - const rawValue = window.sessionStorage.getItem(key) - if (rawValue !== null) { - try { - const parsedValue = JSON.parse(rawValue) - if (typeof parsedValue === "boolean") { - setOpen(parsedValue) - } - } catch { - // Ignore invalid storage values and keep defaults. - } - } - - const raf = window.requestAnimationFrame(() => { - setShouldAnimate(false) - setIsInitialized(true) - }) - - return () => window.cancelAnimationFrame(raf) - }, [key, setOpen, setShouldAnimate]) - - useEffect(() => { - if (!shouldAnimate || !options.clearAnimationAfterMs) { - return - } - const timeout = window.setTimeout(() => { - setShouldAnimate(false) - }, options.clearAnimationAfterMs) - return () => window.clearTimeout(timeout) - }, [options.clearAnimationAfterMs, setShouldAnimate, shouldAnimate]) - - const setOpenWithTransition = (value: boolean | ((prev: boolean) => boolean)) => { - setShouldAnimate(true) - if (typeof window === "undefined") { - setOpen(value) - return - } - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current) - } - rafRef.current = window.requestAnimationFrame(() => { - setOpen(value) - rafRef.current = null - }) - } - - useEffect(() => { - return () => { - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }, []) - - return { - isOpen, - isInitialized, - shouldAnimate, - setOpen, - setOpenWithTransition - } as const -} diff --git a/src/features/sidebar/data/useSidebarOpen.ts b/src/features/sidebar/data/useSidebarOpen.ts index 44c065c8..1a8dc151 100644 --- a/src/features/sidebar/data/useSidebarOpen.ts +++ b/src/features/sidebar/data/useSidebarOpen.ts @@ -1,7 +1,5 @@ -import usePanelOpen from "./usePanelOpen" +import { useSessionStorage } from "usehooks-ts" -type Options = { clearAnimationAfterMs?: number } - -export default function useSidebarOpen(options: Options = {}) { - return usePanelOpen("isSidebarOpen", true, options) -} +export default function useSidebarOpen() { + return useSessionStorage("isSidebarOpen", true, { initializeWithValue: false }) +} \ No newline at end of file diff --git a/src/features/sidebar/view/SecondarySplitHeader.tsx b/src/features/sidebar/view/SecondarySplitHeader.tsx index bc6bd9b2..7f6570ba 100644 --- a/src/features/sidebar/view/SecondarySplitHeader.tsx +++ b/src/features/sidebar/view/SecondarySplitHeader.tsx @@ -20,8 +20,8 @@ const SecondarySplitHeader = ({ mobileToolbar?: React.ReactNode children?: React.ReactNode }) => { - const { isOpen: isSidebarOpen, setOpenWithTransition: setSidebarOpen } = useSidebarOpen() - const { isOpen: isDiffbarOpen, setOpenWithTransition: setDiffbarOpen } = useDiffbarOpen() + const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() + const [isDiffbarOpen, setDiffbarOpen] = useDiffbarOpen() const [isMobileToolbarVisible, setMobileToolbarVisible] = useSessionStorage("isMobileToolbarVisible", true) const { specification } = useProjectSelection() return ( @@ -160,4 +160,4 @@ const ToggleDiffButton = ({ ) -} +} \ No newline at end of file diff --git a/src/features/sidebar/view/internal/ClientSplitView.tsx b/src/features/sidebar/view/internal/ClientSplitView.tsx index 7400e18b..fd4f1bc1 100644 --- a/src/features/sidebar/view/internal/ClientSplitView.tsx +++ b/src/features/sidebar/view/internal/ClientSplitView.tsx @@ -6,6 +6,8 @@ import { isMac, useKeyboardShortcut, SidebarTogglableContext } from "@/common" import { useSidebarOpen } from "../../data" import useDiffbarOpen from "../../data/useDiffbarOpen" import { useProjectSelection } from "@/features/projects/data" +import ClientSplitViewTransitionContext from "./ClientSplitViewTransitionContext" +import useClientSplitViewTransitionEnabled from "./useClientSplitViewTransitionEnabled" import PrimaryContainer from "./primary/Container" import SecondaryContainer from "./secondary/Container" import RightContainer from "./tertiary/RightContainer" @@ -21,96 +23,86 @@ const ClientSplitView = ({ children?: React.ReactNode sidebarRight?: React.ReactNode }) => { - const theme = useTheme() - const panelTransitionDuration = Math.max( - theme.transitions.duration.enteringScreen, - theme.transitions.duration.leavingScreen - ) - const { - isOpen: isSidebarOpen, - isInitialized: isSidebarInitialized, - shouldAnimate: shouldAnimateSidebar, - setOpen: setSidebarOpen, - setOpenWithTransition: setSidebarOpenWithTransition - } = useSidebarOpen({ clearAnimationAfterMs: panelTransitionDuration }) - const { - isOpen: isDiffbarOpen, - isInitialized: isDiffbarInitialized, - shouldAnimate: shouldAnimateDiffbar, - setOpen: setDiffbarOpen, - setOpenWithTransition: setDiffbarOpenWithTransition - } = useDiffbarOpen({ clearAnimationAfterMs: panelTransitionDuration }) + const { isMounted, isTransitionsEnabled } = useClientSplitViewTransitionEnabled() + const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() + const [isRightSidebarOpen, setRightSidebarOpen] = useDiffbarOpen() const { specification } = useProjectSelection() const isSidebarTogglable = useContext(SidebarTogglableContext) + const theme = useTheme() + // Determine if the screen size is small or larger const isSM = useMediaQuery(theme.breakpoints.up("sm")) - const isLayoutReady = isSidebarInitialized && isDiffbarInitialized - + useEffect(() => { if (!isSidebarTogglable && !isSidebarOpen) { setSidebarOpen(true) } }, [isSidebarOpen, isSidebarTogglable, setSidebarOpen]) + // Close diff sidebar if no specification is selected useEffect(() => { - if (!specification && isDiffbarOpen) { - setDiffbarOpen(false) + if (!specification && isRightSidebarOpen) { + setRightSidebarOpen(false) } - }, [isDiffbarOpen, setDiffbarOpen, specification]) - + }, [specification, isRightSidebarOpen, setRightSidebarOpen]) useKeyboardShortcut(event => { const isActionKey = isMac() ? event.metaKey : event.ctrlKey if (isActionKey && event.key === ".") { event.preventDefault() if (isSidebarTogglable) { - setSidebarOpenWithTransition(!isSidebarOpen) + setSidebarOpen(!isSidebarOpen) } } - }, [isSidebarOpen, isSidebarTogglable, setSidebarOpenWithTransition]) + }, [isSidebarTogglable, setSidebarOpen]) useKeyboardShortcut(event => { const isActionKey = isMac() ? event.metaKey : event.ctrlKey if (isDiffFeatureEnabled && isActionKey && event.key === "k") { event.preventDefault() - setDiffbarOpenWithTransition(!isDiffbarOpen) + setRightSidebarOpen(!isRightSidebarOpen) } - }, [isDiffbarOpen, setDiffbarOpenWithTransition]) - + }, [isRightSidebarOpen, setRightSidebarOpen]) + const sidebarWidth = 320 const diffWidth = 320 return ( - - setSidebarOpenWithTransition(false)} - disableTransition={!shouldAnimateSidebar} - > - {sidebar} - - - {children} - - setDiffbarOpenWithTransition(false)} - disableTransition={!shouldAnimateDiffbar} + + - {sidebarRight} - - + setSidebarOpen(false)} + > + {sidebar} + + + {children} + + setRightSidebarOpen(false)} + > + {sidebarRight} + + + ) } diff --git a/src/features/sidebar/view/internal/ClientSplitViewTransitionContext.tsx b/src/features/sidebar/view/internal/ClientSplitViewTransitionContext.tsx new file mode 100644 index 00000000..a132f0a4 --- /dev/null +++ b/src/features/sidebar/view/internal/ClientSplitViewTransitionContext.tsx @@ -0,0 +1,14 @@ +"use client" + +import { createContext } from "react" + +type ClientSplitViewTransitionContextValue = { + isTransitionsEnabled: boolean +} + +const ClientSplitViewTransitionContext = createContext({ + isTransitionsEnabled: true +}) + +export default ClientSplitViewTransitionContext + diff --git a/src/features/sidebar/view/internal/primary/Container.tsx b/src/features/sidebar/view/internal/primary/Container.tsx index cf9d142a..ff4ba2a9 100644 --- a/src/features/sidebar/view/internal/primary/Container.tsx +++ b/src/features/sidebar/view/internal/primary/Container.tsx @@ -1,20 +1,20 @@ "use client" +import { useContext } from "react" import { SxProps } from "@mui/system" import { Drawer as MuiDrawer } from "@mui/material" import { useTheme } from "@mui/material/styles" +import ClientSplitViewTransitionContext from "../ClientSplitViewTransitionContext" const PrimaryContainer = ({ width, isOpen, onClose, - disableTransition, children }: { width: number isOpen: boolean onClose?: () => void - disableTransition?: boolean children?: React.ReactNode }) => { return ( @@ -24,7 +24,6 @@ const PrimaryContainer = ({ width={width} isOpen={isOpen} onClose={onClose} - disableTransition={disableTransition} keepMounted={true} sx={{ display: { xs: "block", sm: "none" } }} > @@ -34,7 +33,6 @@ const PrimaryContainer = ({ variant="persistent" width={width} isOpen={isOpen} - disableTransition={disableTransition} keepMounted={false} sx={{ display: { xs: "none", sm: "block" } }} > @@ -51,7 +49,6 @@ const InnerPrimaryContainer = ({ width, isOpen, onClose, - disableTransition, keepMounted, sx, children @@ -60,19 +57,19 @@ const InnerPrimaryContainer = ({ width: number isOpen: boolean onClose?: () => void - disableTransition?: boolean keepMounted?: boolean sx: SxProps, children?: React.ReactNode }) => { const theme = useTheme() + const { isTransitionsEnabled } = useContext(ClientSplitViewTransitionContext) return ( diff --git a/src/features/sidebar/view/internal/secondary/Container.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx index cfc680f4..7eec2089 100644 --- a/src/features/sidebar/view/internal/secondary/Container.tsx +++ b/src/features/sidebar/view/internal/secondary/Container.tsx @@ -1,7 +1,9 @@ +import { useContext } from "react" import { SxProps } from "@mui/system" import { Box, Stack } from "@mui/material" import { styled } from "@mui/material/styles" import CustomTopLoader from "@/common/ui/CustomTopLoader" +import ClientSplitViewTransitionContext from "../ClientSplitViewTransitionContext" const SecondaryContainer = ({ sidebarWidth, @@ -9,27 +11,23 @@ const SecondaryContainer = ({ diffWidth, offsetDiffContent, children, - isSM, - disableTransition, + isSM }: { sidebarWidth: number offsetContent: boolean diffWidth?: number offsetDiffContent?: boolean children?: React.ReactNode, - isSM: boolean, - disableTransition?: boolean, + isSM: boolean }) => { const sx = { overflow: "hidden" } return ( <> {children} @@ -46,7 +44,6 @@ interface WrapperStackProps { readonly isSidebarOpen: boolean readonly diffWidth: number readonly isDiffOpen: boolean - readonly disableTransition?: boolean } const WrapperStack = styled(Stack, { @@ -54,15 +51,15 @@ const WrapperStack = styled(Stack, { prop !== "isSidebarOpen" && prop !== "sidebarWidth" && prop !== "diffWidth" && - prop !== "isDiffOpen" && - prop !== "disableTransition" -})(({ theme, sidebarWidth, isSidebarOpen, diffWidth, isDiffOpen, disableTransition }) => { + prop !== "isDiffOpen" +})(({ theme, sidebarWidth, isSidebarOpen, diffWidth, isDiffOpen }) => { const marginStyles = { marginLeft: isSidebarOpen ? 0 : `-${sidebarWidth}px`, marginRight: isDiffOpen ? 0 : `-${diffWidth}px`, } - - if (disableTransition) { + + const { isTransitionsEnabled } = useContext(ClientSplitViewTransitionContext) + if (!isTransitionsEnabled) { return { transition: "none", ...marginStyles @@ -89,7 +86,6 @@ const InnerSecondaryContainer = ({ isSidebarOpen, diffWidth, isDiffOpen, - disableTransition, children, sx }: { @@ -97,7 +93,6 @@ const InnerSecondaryContainer = ({ isSidebarOpen: boolean diffWidth: number isDiffOpen: boolean - disableTransition?: boolean children: React.ReactNode sx?: SxProps }) => { @@ -109,7 +104,6 @@ const InnerSecondaryContainer = ({ isSidebarOpen={isSidebarOpen} diffWidth={diffWidth} isDiffOpen={isDiffOpen} - disableTransition={disableTransition} sx={{ ...sx, width: "100%", overflowY: "auto" }} > diff --git a/src/features/sidebar/view/internal/tertiary/RightContainer.tsx b/src/features/sidebar/view/internal/tertiary/RightContainer.tsx index c1af7172..1f90ea78 100644 --- a/src/features/sidebar/view/internal/tertiary/RightContainer.tsx +++ b/src/features/sidebar/view/internal/tertiary/RightContainer.tsx @@ -1,20 +1,20 @@ 'use client' +import { useContext } from "react" import { SxProps } from "@mui/system" import { Drawer as MuiDrawer } from "@mui/material" import { useTheme } from "@mui/material/styles" +import ClientSplitViewTransitionContext from "../ClientSplitViewTransitionContext" const RightContainer = ({ width, isOpen, onClose, - disableTransition, children }: { width: number isOpen: boolean onClose?: () => void - disableTransition?: boolean children?: React.ReactNode }) => { return ( @@ -24,7 +24,6 @@ const RightContainer = ({ width={width} isOpen={isOpen} onClose={onClose} - disableTransition={disableTransition} keepMounted={true} sx={{ display: { xs: "block", sm: "none" } }} > @@ -34,7 +33,6 @@ const RightContainer = ({ variant="persistent" width={width} isOpen={isOpen} - disableTransition={disableTransition} keepMounted={false} sx={{ display: { xs: "none", sm: "block" } }} > @@ -51,7 +49,6 @@ const InnerRightContainer = ({ width, isOpen, onClose, - disableTransition, keepMounted, sx, children @@ -60,19 +57,19 @@ const InnerRightContainer = ({ width: number isOpen: boolean onClose?: () => void - disableTransition?: boolean keepMounted?: boolean sx: SxProps children?: React.ReactNode }) => { const theme = useTheme() + const { isTransitionsEnabled } = useContext(ClientSplitViewTransitionContext) return ( diff --git a/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts b/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts new file mode 100644 index 00000000..b4fefffc --- /dev/null +++ b/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts @@ -0,0 +1,26 @@ +"use client" + +import { useEffect, useState } from "react" + +export default function useClientSplitViewTransitionEnabled() { + const [isMounted, setMounted] = useState(false) + const [isTransitionsEnabled, setTransitionsEnabled] = useState(false) + + useEffect(() => { + // Track first render to avoid showing default state. + setMounted(true) + }, []) + + useEffect(() => { + if (!isMounted) { + return + } + // Enable transitions only after the first mounted paint. + const frame = window.requestAnimationFrame(() => { + setTransitionsEnabled(true) + }) + return () => window.cancelAnimationFrame(frame) + }, [isMounted]) + + return { isMounted, isTransitionsEnabled } +} From 64ea18cd47c4fe2cd65cc987c66d76a0628026ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 09:46:35 +0100 Subject: [PATCH 16/27] Fixes linting error --- .../view/internal/useClientSplitViewTransitionEnabled.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts b/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts index b4fefffc..d8442635 100644 --- a/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts +++ b/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts @@ -8,7 +8,10 @@ export default function useClientSplitViewTransitionEnabled() { useEffect(() => { // Track first render to avoid showing default state. - setMounted(true) + const frame = window.requestAnimationFrame(() => { + setMounted(true) + }) + return () => window.cancelAnimationFrame(frame) }, []) useEffect(() => { From 428842e856daacb6fbce2c061e5fb9d95f392226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=2E=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 09:47:15 +0100 Subject: [PATCH 17/27] Update useCloseSidebarOnSelection.ts --- src/features/sidebar/data/useCloseSidebarOnSelection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/sidebar/data/useCloseSidebarOnSelection.ts b/src/features/sidebar/data/useCloseSidebarOnSelection.ts index c3779952..bc7fa864 100644 --- a/src/features/sidebar/data/useCloseSidebarOnSelection.ts +++ b/src/features/sidebar/data/useCloseSidebarOnSelection.ts @@ -15,4 +15,4 @@ export default function useCloseSidebarOnSelection() { } } } -} \ No newline at end of file +} From 5a25165d782e4d1390aa50ed330f5615f6776923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=2E=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 09:47:35 +0100 Subject: [PATCH 18/27] Fix missing newline at end of Container.tsx From 9043c816c389490481f4007756f51ec29da688f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 09:51:22 +0100 Subject: [PATCH 19/27] Reduces changes --- src/features/sidebar/view/SecondarySplitHeader.tsx | 2 +- src/features/sidebar/view/internal/ClientSplitView.tsx | 6 +++--- src/features/sidebar/view/internal/secondary/Container.tsx | 7 ++----- .../sidebar/view/internal/tertiary/RightContainer.tsx | 2 +- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/features/sidebar/view/SecondarySplitHeader.tsx b/src/features/sidebar/view/SecondarySplitHeader.tsx index 7f6570ba..870bae8b 100644 --- a/src/features/sidebar/view/SecondarySplitHeader.tsx +++ b/src/features/sidebar/view/SecondarySplitHeader.tsx @@ -160,4 +160,4 @@ const ToggleDiffButton = ({ ) -} \ No newline at end of file +} diff --git a/src/features/sidebar/view/internal/ClientSplitView.tsx b/src/features/sidebar/view/internal/ClientSplitView.tsx index fd4f1bc1..4160b393 100644 --- a/src/features/sidebar/view/internal/ClientSplitView.tsx +++ b/src/features/sidebar/view/internal/ClientSplitView.tsx @@ -31,13 +31,13 @@ const ClientSplitView = ({ const theme = useTheme() // Determine if the screen size is small or larger const isSM = useMediaQuery(theme.breakpoints.up("sm")) - + useEffect(() => { if (!isSidebarTogglable && !isSidebarOpen) { setSidebarOpen(true) } }, [isSidebarOpen, isSidebarTogglable, setSidebarOpen]) - + // Close diff sidebar if no specification is selected useEffect(() => { if (!specification && isRightSidebarOpen) { @@ -53,7 +53,7 @@ const ClientSplitView = ({ } } }, [isSidebarTogglable, setSidebarOpen]) - + useKeyboardShortcut(event => { const isActionKey = isMac() ? event.metaKey : event.ctrlKey if (isDiffFeatureEnabled && isActionKey && event.key === "k") { diff --git a/src/features/sidebar/view/internal/secondary/Container.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx index 7eec2089..2ec649dd 100644 --- a/src/features/sidebar/view/internal/secondary/Container.tsx +++ b/src/features/sidebar/view/internal/secondary/Container.tsx @@ -47,11 +47,7 @@ interface WrapperStackProps { } const WrapperStack = styled(Stack, { - shouldForwardProp: (prop) => - prop !== "isSidebarOpen" && - prop !== "sidebarWidth" && - prop !== "diffWidth" && - prop !== "isDiffOpen" + shouldForwardProp: (prop) => prop !== "isSidebarOpen" && prop !== "sidebarWidth" && prop !== "diffWidth" && prop !== "isDiffOpen" })(({ theme, sidebarWidth, isSidebarOpen, diffWidth, isDiffOpen }) => { const marginStyles = { marginLeft: isSidebarOpen ? 0 : `-${sidebarWidth}px`, @@ -105,6 +101,7 @@ const InnerSecondaryContainer = ({ diffWidth={diffWidth} isDiffOpen={isDiffOpen} sx={{ ...sx, width: "100%", overflowY: "auto" }} + > {children} diff --git a/src/features/sidebar/view/internal/tertiary/RightContainer.tsx b/src/features/sidebar/view/internal/tertiary/RightContainer.tsx index 1f90ea78..22d62dd6 100644 --- a/src/features/sidebar/view/internal/tertiary/RightContainer.tsx +++ b/src/features/sidebar/view/internal/tertiary/RightContainer.tsx @@ -89,4 +89,4 @@ const InnerRightContainer = ({ {children} ) -} +} \ No newline at end of file From b788fc643dd6ea636fdd091a733a3e877c4ee4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 09:53:30 +0100 Subject: [PATCH 20/27] Reduces changes --- src/features/sidebar/view/internal/ClientSplitView.tsx | 4 ++-- src/features/sidebar/view/internal/primary/Container.tsx | 2 +- src/features/sidebar/view/internal/secondary/Container.tsx | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/features/sidebar/view/internal/ClientSplitView.tsx b/src/features/sidebar/view/internal/ClientSplitView.tsx index 4160b393..0ac6ad99 100644 --- a/src/features/sidebar/view/internal/ClientSplitView.tsx +++ b/src/features/sidebar/view/internal/ClientSplitView.tsx @@ -53,7 +53,7 @@ const ClientSplitView = ({ } } }, [isSidebarTogglable, setSidebarOpen]) - + useKeyboardShortcut(event => { const isActionKey = isMac() ? event.metaKey : event.ctrlKey if (isDiffFeatureEnabled && isActionKey && event.key === "k") { @@ -61,7 +61,7 @@ const ClientSplitView = ({ setRightSidebarOpen(!isRightSidebarOpen) } }, [isRightSidebarOpen, setRightSidebarOpen]) - + const sidebarWidth = 320 const diffWidth = 320 diff --git a/src/features/sidebar/view/internal/primary/Container.tsx b/src/features/sidebar/view/internal/primary/Container.tsx index ff4ba2a9..bcdedf70 100644 --- a/src/features/sidebar/view/internal/primary/Container.tsx +++ b/src/features/sidebar/view/internal/primary/Container.tsx @@ -89,4 +89,4 @@ const InnerPrimaryContainer = ({ {children} ) -} +} \ No newline at end of file diff --git a/src/features/sidebar/view/internal/secondary/Container.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx index 2ec649dd..9c38e102 100644 --- a/src/features/sidebar/view/internal/secondary/Container.tsx +++ b/src/features/sidebar/view/internal/secondary/Container.tsx @@ -11,19 +11,20 @@ const SecondaryContainer = ({ diffWidth, offsetDiffContent, children, - isSM + isSM, }: { sidebarWidth: number offsetContent: boolean diffWidth?: number offsetDiffContent?: boolean children?: React.ReactNode, - isSM: boolean + isSM: boolean, }) => { const sx = { overflow: "hidden" } return ( <> Date: Tue, 6 Jan 2026 09:55:28 +0100 Subject: [PATCH 21/27] Reduces changes --- src/features/sidebar/view/internal/secondary/Container.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/sidebar/view/internal/secondary/Container.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx index 9c38e102..fce26077 100644 --- a/src/features/sidebar/view/internal/secondary/Container.tsx +++ b/src/features/sidebar/view/internal/secondary/Container.tsx @@ -24,9 +24,9 @@ const SecondaryContainer = ({ return ( <> Date: Tue, 6 Jan 2026 09:58:00 +0100 Subject: [PATCH 22/27] Removes useContext from styled component --- .../sidebar/view/internal/secondary/Container.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/features/sidebar/view/internal/secondary/Container.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx index fce26077..00bca289 100644 --- a/src/features/sidebar/view/internal/secondary/Container.tsx +++ b/src/features/sidebar/view/internal/secondary/Container.tsx @@ -21,6 +21,7 @@ const SecondaryContainer = ({ isSM: boolean, }) => { const sx = { overflow: "hidden" } + const { isTransitionsEnabled } = useContext(ClientSplitViewTransitionContext) return ( <> {children} @@ -45,17 +47,17 @@ interface WrapperStackProps { readonly isSidebarOpen: boolean readonly diffWidth: number readonly isDiffOpen: boolean + readonly isTransitionsEnabled: boolean } const WrapperStack = styled(Stack, { - shouldForwardProp: (prop) => prop !== "isSidebarOpen" && prop !== "sidebarWidth" && prop !== "diffWidth" && prop !== "isDiffOpen" -})(({ theme, sidebarWidth, isSidebarOpen, diffWidth, isDiffOpen }) => { + shouldForwardProp: (prop) => prop !== "isSidebarOpen" && prop !== "sidebarWidth" && prop !== "diffWidth" && prop !== "isDiffOpen" && prop !== "isTransitionsEnabled" +})(({ theme, sidebarWidth, isSidebarOpen, diffWidth, isDiffOpen, isTransitionsEnabled }) => { const marginStyles = { marginLeft: isSidebarOpen ? 0 : `-${sidebarWidth}px`, marginRight: isDiffOpen ? 0 : `-${diffWidth}px`, } - const { isTransitionsEnabled } = useContext(ClientSplitViewTransitionContext) if (!isTransitionsEnabled) { return { transition: "none", @@ -83,6 +85,7 @@ const InnerSecondaryContainer = ({ isSidebarOpen, diffWidth, isDiffOpen, + isTransitionsEnabled, children, sx }: { @@ -90,6 +93,7 @@ const InnerSecondaryContainer = ({ isSidebarOpen: boolean diffWidth: number isDiffOpen: boolean + isTransitionsEnabled: boolean children: React.ReactNode sx?: SxProps }) => { @@ -101,6 +105,7 @@ const InnerSecondaryContainer = ({ isSidebarOpen={isSidebarOpen} diffWidth={diffWidth} isDiffOpen={isDiffOpen} + isTransitionsEnabled={isTransitionsEnabled} sx={{ ...sx, width: "100%", overflowY: "auto" }} > From 58478741758b37d551573aa6bb91591fb0dcf78b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 10:00:28 +0100 Subject: [PATCH 23/27] Improves formatting --- .../sidebar/view/internal/secondary/Container.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/features/sidebar/view/internal/secondary/Container.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx index 00bca289..fb06a1b2 100644 --- a/src/features/sidebar/view/internal/secondary/Container.tsx +++ b/src/features/sidebar/view/internal/secondary/Container.tsx @@ -51,8 +51,14 @@ interface WrapperStackProps { } const WrapperStack = styled(Stack, { - shouldForwardProp: (prop) => prop !== "isSidebarOpen" && prop !== "sidebarWidth" && prop !== "diffWidth" && prop !== "isDiffOpen" && prop !== "isTransitionsEnabled" -})(({ theme, sidebarWidth, isSidebarOpen, diffWidth, isDiffOpen, isTransitionsEnabled }) => { + shouldForwardProp: (prop) => + prop !== "isSidebarOpen" && + prop !== "sidebarWidth" && + prop !== "diffWidth" && + prop !== "isDiffOpen" && + prop !== "isTransitionsEnabled", +})( + ({ theme, sidebarWidth, isSidebarOpen, diffWidth, isDiffOpen, isTransitionsEnabled }) => { const marginStyles = { marginLeft: isSidebarOpen ? 0 : `-${sidebarWidth}px`, marginRight: isDiffOpen ? 0 : `-${diffWidth}px`, From 6e613dfba030eb0e5b0b35083b5747ab9803afbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 10:05:40 +0100 Subject: [PATCH 24/27] Fixes spacing --- src/features/sidebar/view/internal/secondary/Container.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/sidebar/view/internal/secondary/Container.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx index fb06a1b2..df838891 100644 --- a/src/features/sidebar/view/internal/secondary/Container.tsx +++ b/src/features/sidebar/view/internal/secondary/Container.tsx @@ -25,9 +25,8 @@ const SecondaryContainer = ({ return ( <> Date: Tue, 6 Jan 2026 10:14:38 +0100 Subject: [PATCH 25/27] Adds comment --- .../useClientSplitViewTransitionEnabled.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts b/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts index d8442635..4b6c839b 100644 --- a/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts +++ b/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts @@ -25,5 +25,19 @@ export default function useClientSplitViewTransitionEnabled() { return () => window.cancelAnimationFrame(frame) }, [isMounted]) + // NOTE (2026-01-06): + + // There is a potential edge-case where the component unmounts between + // consecutive requestAnimationFrame calls. If that happens after setMounted(true) + // runs but before the second requestAnimationFrame fires, the second frame won't be + // cancelled by the first effect's cleanup. + // Quick mitigation idea: store both RAF IDs in refs (e.g. mountRef, transitionRef), + // clear the ref inside each RAF callback, and cancel any remaining non-null refs + // from a single unmount cleanup to guarantee no callback runs after unmount. + // + // This fix is not implemented because it would complicate the implementation + // and it's unclear how frequently the edge-case occurs n practice. + // Revisit if this issue is observed in production. + return { isMounted, isTransitionsEnabled } } From 4f30cc2a09299673be74947319ac75d79a1c950b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=2E=20St=C3=B8vring?= Date: Tue, 6 Jan 2026 10:23:25 +0100 Subject: [PATCH 26/27] Update src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../view/internal/useClientSplitViewTransitionEnabled.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts b/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts index 4b6c839b..4ec5553a 100644 --- a/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts +++ b/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts @@ -36,7 +36,7 @@ export default function useClientSplitViewTransitionEnabled() { // from a single unmount cleanup to guarantee no callback runs after unmount. // // This fix is not implemented because it would complicate the implementation - // and it's unclear how frequently the edge-case occurs n practice. + // and it's unclear how frequently the edge-case occurs in practice. // Revisit if this issue is observed in production. return { isMounted, isTransitionsEnabled } From 269b45a8a9ebfb1e58477dc83b8a7a8133e18a01 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Tue, 6 Jan 2026 11:01:14 +0100 Subject: [PATCH 27/27] Hide diff-related UI elements when diff feature is disabled - Gate hasChanges indicator in Selector with isDiffFeatureEnabled - Conditionally render the divider in SecondarySplitHeader - Fix trailing whitespace in PrimaryContainer --- src/features/projects/view/toolbar/MobileToolbar.tsx | 4 +++- src/features/projects/view/toolbar/TrailingToolbarItem.tsx | 4 +++- src/features/sidebar/view/SecondarySplitHeader.tsx | 4 +++- src/features/sidebar/view/internal/primary/Container.tsx | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/features/projects/view/toolbar/MobileToolbar.tsx b/src/features/projects/view/toolbar/MobileToolbar.tsx index 821af89a..61e68c70 100644 --- a/src/features/projects/view/toolbar/MobileToolbar.tsx +++ b/src/features/projects/view/toolbar/MobileToolbar.tsx @@ -4,6 +4,8 @@ import { Stack } from "@mui/material" import Selector from "./Selector" import { useProjectSelection } from "../../data" +const isDiffFeatureEnabled = process.env.NEXT_PUBLIC_ENABLE_DIFF_SIDEBAR === "true" + const MobileToolbar = () => { const { project, @@ -31,7 +33,7 @@ const MobileToolbar = () => { items={version.specifications.map(spec => ({ id: spec.id, name: spec.name, - hasChanges: !!spec.diffURL + hasChanges: isDiffFeatureEnabled && !!spec.diffURL }))} selection={specification.id} onSelect={selectSpecification} diff --git a/src/features/projects/view/toolbar/TrailingToolbarItem.tsx b/src/features/projects/view/toolbar/TrailingToolbarItem.tsx index ad3523aa..151f0232 100644 --- a/src/features/projects/view/toolbar/TrailingToolbarItem.tsx +++ b/src/features/projects/view/toolbar/TrailingToolbarItem.tsx @@ -7,6 +7,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { faPenToSquare } from "@fortawesome/free-regular-svg-icons" import { useProjectSelection } from "../../data" +const isDiffFeatureEnabled = process.env.NEXT_PUBLIC_ENABLE_DIFF_SIDEBAR === "true" + const TrailingToolbarItem = () => { const { project, @@ -62,7 +64,7 @@ const TrailingToolbarItem = () => { items={version.specifications.map(spec => ({ id: spec.id, name: spec.name, - hasChanges: !!spec.diffURL + hasChanges: isDiffFeatureEnabled && !!spec.diffURL }))} selection={specification.id} onSelect={selectSpecification} diff --git a/src/features/sidebar/view/SecondarySplitHeader.tsx b/src/features/sidebar/view/SecondarySplitHeader.tsx index 870bae8b..242d7362 100644 --- a/src/features/sidebar/view/SecondarySplitHeader.tsx +++ b/src/features/sidebar/view/SecondarySplitHeader.tsx @@ -41,7 +41,9 @@ const SecondarySplitHeader = ({ {children} - + {isDiffFeatureEnabled && ( + + )} {mobileToolbar && ( { return ( <> -