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 && ( { + const { isMounted, isTransitionsEnabled } = useClientSplitViewTransitionEnabled() const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() const [isRightSidebarOpen, setRightSidebarOpen] = useDiffbarOpen() const { specification } = useProjectSelection() @@ -63,25 +66,43 @@ const ClientSplitView = ({ const diffWidth = 320 return ( - - setSidebarOpen(false)} + + - {sidebar} - - - {children} - - setRightSidebarOpen(false)} - > - {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 49a5f45e..648ff289 100644 --- a/src/features/sidebar/view/internal/primary/Container.tsx +++ b/src/features/sidebar/view/internal/primary/Container.tsx @@ -1,8 +1,10 @@ "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, @@ -17,7 +19,7 @@ const PrimaryContainer = ({ }) => { return ( <> - { 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 6a3a9ead..df838891 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, @@ -19,14 +21,15 @@ const SecondaryContainer = ({ isSM: boolean, }) => { const sx = { overflow: "hidden" } + const { isTransitionsEnabled } = useContext(ClientSplitViewTransitionContext) return ( <> {children} @@ -43,18 +46,36 @@ 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`, + } + + if (!isTransitionsEnabled) { + 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 +90,7 @@ const InnerSecondaryContainer = ({ isSidebarOpen, diffWidth, isDiffOpen, + isTransitionsEnabled, children, sx }: { @@ -76,6 +98,7 @@ const InnerSecondaryContainer = ({ isSidebarOpen: boolean diffWidth: number isDiffOpen: boolean + isTransitionsEnabled: boolean children: React.ReactNode sx?: SxProps }) => { @@ -87,6 +110,7 @@ const InnerSecondaryContainer = ({ isSidebarOpen={isSidebarOpen} diffWidth={diffWidth} isDiffOpen={isDiffOpen} + isTransitionsEnabled={isTransitionsEnabled} 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..22d62dd6 100644 --- a/src/features/sidebar/view/internal/tertiary/RightContainer.tsx +++ b/src/features/sidebar/view/internal/tertiary/RightContainer.tsx @@ -1,8 +1,10 @@ '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, @@ -60,12 +62,14 @@ const InnerRightContainer = ({ 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..4ec5553a --- /dev/null +++ b/src/features/sidebar/view/internal/useClientSplitViewTransitionEnabled.ts @@ -0,0 +1,43 @@ +"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. + const frame = window.requestAnimationFrame(() => { + setMounted(true) + }) + return () => window.cancelAnimationFrame(frame) + }, []) + + useEffect(() => { + if (!isMounted) { + return + } + // Enable transitions only after the first mounted paint. + const frame = window.requestAnimationFrame(() => { + setTransitionsEnabled(true) + }) + 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 in practice. + // Revisit if this issue is observed in production. + + return { isMounted, isTransitionsEnabled } +}