diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/api-id-navbar.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/api-id-navbar.tsx index 76f28a146f..cb77987ab3 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/api-id-navbar.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/api-id-navbar.tsx @@ -4,11 +4,11 @@ import { QuickNavPopover } from "@/components/navbar-popover"; import { NavbarActionButton } from "@/components/navigation/action-button"; import { CopyableIDButton } from "@/components/navigation/copyable-id-button"; import { Navbar } from "@/components/navigation/navbar"; -import { useIsMobile } from "@/hooks/use-mobile"; import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; import { trpc } from "@/lib/trpc/client"; import type { Workspace } from "@unkey/db"; import { ChevronExpandY, Gear, Nodes, Plus, TaskUnchecked } from "@unkey/icons"; +import { useIsMobile } from "@unkey/ui"; import { CreateKeyDialog } from "./_components/create-key"; import { KeySettingsDialog } from "./_components/key-settings-dialog"; @@ -101,7 +101,7 @@ const NavbarContent = ({ keyId, activePage, workspace, - isMobile, + isMobile = false, layoutData, }: NavbarContentProps) => { const shouldFetchKey = Boolean(keyspaceId && keyId); @@ -226,7 +226,8 @@ const NavbarContent = ({ export const ApisNavbar = ({ apiId, keyspaceId, keyId, activePage }: ApisNavbarProps) => { const workspace = useWorkspaceNavigation(); - const isMobile = useIsMobile(); + // Default to false (desktop) to prevent hydration mismatches + const isMobile = useIsMobile({ defaultValue: false }); // Only make the query if we have a valid apiId const { diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx index 4a4d36a561..670c20b8eb 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/deployments/components/table/deployments-list.tsx @@ -1,10 +1,10 @@ "use client"; import { VirtualTable } from "@/components/virtual-table/index"; import type { Column } from "@/components/virtual-table/types"; -import { useIsMobile } from "@/hooks/use-mobile"; import type { Deployment, Environment } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; import { BookBookmark, CodeBranch, Cube } from "@unkey/icons"; +import { useIsMobile } from "@unkey/ui"; import { Button, Empty, TimestampInfo } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import dynamic from "next/dynamic"; @@ -46,7 +46,8 @@ export const DeploymentsList = () => { deployment: Deployment; environment?: Environment; } | null>(null); - const isCompactView = useIsMobile({ breakpoint: COMPACT_BREAKPOINT }); + // Default to false (wide view) to prevent hydration mismatches + const isCompactView = useIsMobile({ breakpoint: COMPACT_BREAKPOINT, defaultValue: false }); const { liveDeployment, deployments, project } = useDeployments(); diff --git a/apps/dashboard/components/logs/checkbox/filter-item.tsx b/apps/dashboard/components/logs/checkbox/filter-item.tsx index 0c53ddd65e..5aeea60aa6 100644 --- a/apps/dashboard/components/logs/checkbox/filter-item.tsx +++ b/apps/dashboard/components/logs/checkbox/filter-item.tsx @@ -1,6 +1,5 @@ -import { Drover } from "@/components/ui/drover"; import { CaretRight } from "@unkey/icons"; -import { Button, KeyboardButton } from "@unkey/ui"; +import { Button, Drover, KeyboardButton } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import type React from "react"; import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; diff --git a/apps/dashboard/components/logs/checkbox/filters-popover.tsx b/apps/dashboard/components/logs/checkbox/filters-popover.tsx index befe802b6d..0138bdbccf 100644 --- a/apps/dashboard/components/logs/checkbox/filters-popover.tsx +++ b/apps/dashboard/components/logs/checkbox/filters-popover.tsx @@ -1,6 +1,5 @@ -import { Drover } from "@/components/ui/drover"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; -import { KeyboardButton } from "@unkey/ui"; +import { Drover, KeyboardButton } from "@unkey/ui"; import React, { type KeyboardEvent, type PropsWithChildren, diff --git a/apps/dashboard/components/logs/datetime/datetime-popover.tsx b/apps/dashboard/components/logs/datetime/datetime-popover.tsx index ba57628eaa..a6315ecad0 100644 --- a/apps/dashboard/components/logs/datetime/datetime-popover.tsx +++ b/apps/dashboard/components/logs/datetime/datetime-popover.tsx @@ -1,13 +1,13 @@ "use client"; -import { Drawer } from "@/components/ui/drawer"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; -import { useIsMobile } from "@/hooks/use-mobile"; import { cn, processTimeFilters } from "@/lib/utils"; import { ChevronDown } from "@unkey/icons"; +import { useIsMobile } from "@unkey/ui"; import { Button, DateTime, + Drawer, KeyboardButton, Popover, PopoverContent, @@ -51,7 +51,8 @@ export const DatetimePopover = ({ minDate, maxDate, }: DatetimePopoverProps) => { - const isMobile = useIsMobile(); + // Default to false (desktop) to prevent hydration mismatches + const isMobile = useIsMobile({ defaultValue: false }); const [timeRangeOpen, setTimeRangeOpen] = useState(false); const [open, setOpen] = useState(false); useKeyboardShortcut("t", () => setOpen((prev) => !prev)); diff --git a/apps/dashboard/components/ui/sidebar.tsx b/apps/dashboard/components/ui/sidebar.tsx index d60d280780..acf80e259c 100644 --- a/apps/dashboard/components/ui/sidebar.tsx +++ b/apps/dashboard/components/ui/sidebar.tsx @@ -11,8 +11,8 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet"; -import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; +import { useIsMobile } from "@unkey/ui"; import { Separator } from "@unkey/ui"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; @@ -64,7 +64,8 @@ const SidebarProvider = React.forwardRef< }, ref, ) => { - const isMobile = useIsMobile(); + // Default to false (desktop) to prevent hydration mismatches + const isMobile = useIsMobile({ defaultValue: false }); const [openMobile, setOpenMobile] = React.useState(false); // This is the internal state of the sidebar. diff --git a/apps/dashboard/components/virtual-table/index.tsx b/apps/dashboard/components/virtual-table/index.tsx index a902f536ee..0e4049750e 100644 --- a/apps/dashboard/components/virtual-table/index.tsx +++ b/apps/dashboard/components/virtual-table/index.tsx @@ -1,6 +1,6 @@ -import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { CaretDown, CaretExpandY, CaretUp, CircleCaretRight } from "@unkey/icons"; +import { useIsMobile } from "@unkey/ui"; import { Fragment, type Ref, forwardRef, useImperativeHandle, useMemo, useRef } from "react"; import { EmptyState } from "./components/empty-state"; import { LoadMoreFooter } from "./components/loading-indicator"; @@ -65,7 +65,8 @@ export const VirtualTable = forwardRef>( const isGridLayout = config.layoutMode === "grid"; const parentRef = useRef(null); const containerRef = useRef(null); - const isMobile = useIsMobile(); + // Default to false (desktop) to prevent hydration mismatches + const isMobile = useIsMobile({ defaultValue: false }); const hasPadding = config.containerPadding !== "px-0"; diff --git a/apps/dashboard/hooks/use-mobile.tsx b/apps/dashboard/hooks/use-mobile.tsx deleted file mode 100644 index 33920ab763..0000000000 --- a/apps/dashboard/hooks/use-mobile.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from "react"; - -type UseIsMobileOptions = { - breakpoint?: number; -}; - -export function useIsMobile(options: UseIsMobileOptions = {}) { - const { breakpoint = 768 } = options; - const [isMobile, setIsMobile] = React.useState(undefined); - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`); - const onChange = () => { - setIsMobile(window.innerWidth < breakpoint); - }; - mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < breakpoint); - return () => mql.removeEventListener("change", onChange); - }, [breakpoint]); - - return !!isMobile; -} diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 4e6bef6d2b..86adf4e846 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -36,7 +36,6 @@ "@radix-ui/react-switch": "1.0.3", "@radix-ui/react-tabs": "1.1.0", "@radix-ui/react-tooltip": "1.0.7", - "@radix-ui/react-use-controllable-state": "1.2.2", "@tailwindcss/container-queries": "0.1.1", "@tailwindcss/typography": "0.5.12", "@tanstack/query-core": "5.87.1", @@ -100,7 +99,6 @@ "trpc-tools": "0.12.0", "typescript": "5.7.3", "usehooks-ts": "3.1.0", - "vaul": "0.9.0", "zod": "3.23.5" }, "devDependencies": { diff --git a/internal/ui/package.json b/internal/ui/package.json index 592bb21273..ac24042c71 100644 --- a/internal/ui/package.json +++ b/internal/ui/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-separator": "1.0.3", "@radix-ui/react-slot": "1.1.0", "@radix-ui/react-tooltip": "1.0.7", + "@radix-ui/react-use-controllable-state": "1.2.2", "@unkey/icons": "workspace:^", "class-variance-authority": "0.7.0", "clsx": "2.1.1", @@ -36,6 +37,7 @@ "react-day-picker": "8.10.1", "sonner": "2.0.5", "tailwind-merge": "2.5.4", + "vaul": "0.9.0", "zod": "3.23.5" } } diff --git a/apps/dashboard/components/ui/drawer.tsx b/internal/ui/src/components/drawer.tsx similarity index 88% rename from apps/dashboard/components/ui/drawer.tsx rename to internal/ui/src/components/drawer.tsx index cd92e4494b..d76286cfd5 100644 --- a/apps/dashboard/components/ui/drawer.tsx +++ b/internal/ui/src/components/drawer.tsx @@ -4,9 +4,9 @@ * Imports * ----------------------------------------------------------------------------*/ -import { cn } from "@/lib/utils"; import * as React from "react"; import { Drawer as Vaul } from "vaul"; +import { cn } from "../lib/utils"; /* ----------------------------------------------------------------------------- * Extend Drawer @@ -55,14 +55,11 @@ DrawerContent.displayName = CONTENT_NAME; * Exports * ---------------------------------------------------------------------------*/ -export const Drawer = Object.assign( - {}, - { - Root: DrawerRoot, - Trigger: DrawerTrigger, - Content: DrawerContent, - Title: DrawerTitle, - Description: DrawerDescription, - Nested: DrawerNested, - }, -); +export const Drawer = Object.assign({ + Root: DrawerRoot, + Trigger: DrawerTrigger, + Content: DrawerContent, + Title: DrawerTitle, + Description: DrawerDescription, + Nested: DrawerNested, +}); diff --git a/apps/dashboard/components/ui/drover.tsx b/internal/ui/src/components/drover.tsx similarity index 94% rename from apps/dashboard/components/ui/drover.tsx rename to internal/ui/src/components/drover.tsx index c9c9ed0160..b22b320d47 100644 --- a/apps/dashboard/components/ui/drover.tsx +++ b/internal/ui/src/components/drover.tsx @@ -1,12 +1,12 @@ "use client"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { createContext } from "@/lib/create-context"; -import { cn } from "@/lib/utils"; import { Slot } from "@radix-ui/react-slot"; import { useControllableState } from "@radix-ui/react-use-controllable-state"; -import { Popover, PopoverContent, PopoverTrigger } from "@unkey/ui"; import React from "react"; +import { useIsMobile } from "../hooks/use-mobile"; +import { createContext } from "../lib/create-context"; +import { cn } from "../lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "./dialog/popover"; import { Drawer } from "./drawer"; type PrimitiveDivProps = React.ComponentPropsWithoutRef<"div">; @@ -35,7 +35,8 @@ const [DroverProvider, useDroverContext] = createContext(ROO const Root: React.FC = (props) => { const { open: openProp, defaultOpen, onOpenChange, children } = props; - const isMobile = useIsMobile(); + // Default to false (desktop) to prevent hydration mismatches and layout shifts + const isMobile = useIsMobile({ defaultValue: false }); const [open, setOpen] = useControllableState({ prop: openProp, defaultProp: defaultOpen ?? false, diff --git a/internal/ui/src/hooks/use-mobile.tsx b/internal/ui/src/hooks/use-mobile.tsx new file mode 100644 index 0000000000..d8698802c0 --- /dev/null +++ b/internal/ui/src/hooks/use-mobile.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +type UseIsMobileOptions = { + breakpoint?: number; + defaultValue?: boolean; +}; + +export function useIsMobile(options: UseIsMobileOptions = {}): boolean { + const { breakpoint = 768, defaultValue = false } = options; + const [isMobile, setIsMobile] = React.useState(defaultValue); + + React.useEffect(() => { + if (typeof window === "undefined") { + return; + } + const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`); + const onChange = (event: MediaQueryListEvent) => { + setIsMobile(event.matches); + }; + mql.addEventListener("change", onChange); + setIsMobile(mql.matches); + return () => mql.removeEventListener("change", onChange); + }, [breakpoint]); + + return isMobile; +} diff --git a/internal/ui/src/index.ts b/internal/ui/src/index.ts index 038f599ca1..f48dbb2913 100644 --- a/internal/ui/src/index.ts +++ b/internal/ui/src/index.ts @@ -15,6 +15,8 @@ export * from "./components/dialog/dialog"; export * from "./components/dialog/dialog-container"; export * from "./components/dialog/confirmation-popover"; export * from "./components/dialog/navigable-dialog"; +export * from "./components/drawer"; +export * from "./components/drover"; export * from "./components/empty"; export * from "./components/form"; export * from "./components/id"; @@ -29,3 +31,4 @@ export * from "./components/timestamp-info"; export * from "./components/tooltip"; export * from "./components/separator"; export * from "./components/toaster"; +export * from "./hooks/use-mobile"; diff --git a/internal/ui/src/lib/create-context.tsx b/internal/ui/src/lib/create-context.tsx new file mode 100644 index 0000000000..8264c9ae19 --- /dev/null +++ b/internal/ui/src/lib/create-context.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +export function createContext( + rootComponentName: string, + defaultContext?: ContextValueType, +) { + const Context = React.createContext(defaultContext); + + const Provider = (props: ContextValueType & { children: React.ReactNode }) => { + const { children } = props; + // Only re-memoize when actual prop values change, not the object reference + // biome-ignore lint/correctness/useExhaustiveDependencies: props object reference changes every render; we track individual prop values instead + const value = React.useMemo( + () => { + const { children: _, ...contextValue } = props; + return contextValue as ContextValueType; + }, + Object.keys(props) + .filter((k) => k !== "children") + .map((k) => (props as Record)[k]), + ); + return {children}; + }; + + function useContext(consumerName: string) { + const context = React.useContext(Context); + if (context !== undefined) { + return context; + } + if (defaultContext !== undefined) { + return defaultContext; + } + // if a defaultContext wasn't specified, it's a required context. + throw new Error(`\`${consumerName}\` must be used within \`${rootComponentName}\``); + } + + Provider.displayName = `${rootComponentName}Provider`; + return [Provider, useContext] as const; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27deecd617..152b3397f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,9 +216,6 @@ importers: '@radix-ui/react-tooltip': specifier: 1.0.7 version: 1.0.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-controllable-state': - specifier: 1.2.2 - version: 1.2.2(@types/react@18.3.11)(react@18.2.0) '@tailwindcss/container-queries': specifier: 0.1.1 version: 0.1.1(tailwindcss@3.4.3) @@ -408,9 +405,6 @@ importers: usehooks-ts: specifier: 3.1.0 version: 3.1.0(react@18.2.0) - vaul: - specifier: 0.9.0 - version: 0.9.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.2.0)(react@18.2.0) zod: specifier: 3.23.8 version: 3.23.8 @@ -1003,6 +997,9 @@ importers: '@radix-ui/react-tooltip': specifier: 1.0.7 version: 1.0.7(@types/react@18.3.11)(react-dom@18.3.1)(react@18.2.0) + '@radix-ui/react-use-controllable-state': + specifier: 1.2.2 + version: 1.2.2(@types/react@18.3.11)(react@18.2.0) '@unkey/icons': specifier: workspace:^ version: link:../icons @@ -1033,6 +1030,9 @@ importers: tailwind-merge: specifier: 2.5.4 version: 2.5.4 + vaul: + specifier: 0.9.0 + version: 0.9.0(@types/react@18.3.11)(react-dom@18.3.1)(react@18.2.0) zod: specifier: 3.23.8 version: 3.23.8 @@ -23695,15 +23695,15 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - /vaul@0.9.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.2.0)(react@18.2.0): + /vaul@0.9.0(@types/react@18.3.11)(react-dom@18.3.1)(react@18.2.0): resolution: {integrity: sha512-bZSySGbAHiTXmZychprnX/dE0EsSige88xtyyL3/MCRbrFotRPQZo7UdydGXZWw+CKbNOw5Ow8gwAo93/nB/Cg==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 dependencies: - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dialog': 1.1.15(@types/react@18.3.11)(react-dom@18.3.1)(react@18.2.0) react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react-dom: 18.3.1(react@18.2.0) transitivePeerDependencies: - '@types/react' - '@types/react-dom'