diff --git a/packages/commons/fdr-utils/src/types.ts b/packages/commons/fdr-utils/src/types.ts index 9e3a743895..1744dd7654 100644 --- a/packages/commons/fdr-utils/src/types.ts +++ b/packages/commons/fdr-utils/src/types.ts @@ -1,5 +1,4 @@ import type { DocsV1Read, FernNavigation } from "@fern-api/fdr-sdk"; -// import { FlattenedApiDefinition } from "./flattenApiDefinition"; export interface ColorsConfig { light: DocsV1Read.ThemeConfig | undefined; diff --git a/packages/ui/app/.storybook/preview.tsx b/packages/ui/app/.storybook/preview.tsx index df6a7d4b88..e3f2d17f4f 100644 --- a/packages/ui/app/.storybook/preview.tsx +++ b/packages/ui/app/.storybook/preview.tsx @@ -1,20 +1,15 @@ import { FernTooltipProvider, Toaster } from "@fern-ui/components"; import { withThemeByClassName } from "@storybook/addon-themes"; import type { Preview } from "@storybook/react"; -import { ThemeProvider } from "next-themes"; import React from "react"; import "../src/next-app/globals.scss"; import "./variables.css"; const globalDecorator = (Story) => ( - - - - - - - - + + + + ); export const decorators = [ globalDecorator, diff --git a/packages/ui/app/package.json b/packages/ui/app/package.json index 99de890370..e8045e1f60 100644 --- a/packages/ui/app/package.json +++ b/packages/ui/app/package.json @@ -104,7 +104,6 @@ "moment": "^2.30.1", "next": "^14.2.4", "next-mdx-remote": "^5.0.0", - "next-themes": "^0.3.0", "nprogress": "^0.2.0", "numeral": "^2.0.6", "parse-numeric-range": "^1.3.0", diff --git a/packages/ui/app/src/api-page/endpoints/EndpointContent.tsx b/packages/ui/app/src/api-page/endpoints/EndpointContent.tsx index 122f6b69bb..b7cf22c2d9 100644 --- a/packages/ui/app/src/api-page/endpoints/EndpointContent.tsx +++ b/packages/ui/app/src/api-page/endpoints/EndpointContent.tsx @@ -6,6 +6,7 @@ import dynamic from "next/dynamic"; import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { useInView } from "react-intersection-observer"; import { useCallbackOne } from "use-memo-one"; +import { useAtomEffect } from "../../atoms"; import { FERN_LANGUAGE_ATOM } from "../../atoms/lang"; import { CONTENT_HEIGHT_ATOM } from "../../atoms/layout"; import { HASH_ATOM } from "../../atoms/location"; @@ -14,7 +15,6 @@ import { store } from "../../atoms/store"; import { FERN_STREAM_ATOM } from "../../atoms/stream"; import { BREAKPOINT_ATOM, MOBILE_SIDEBAR_ENABLED_ATOM } from "../../atoms/viewport"; import { Breadcrumbs } from "../../components/Breadcrumbs"; -import { useAtomEffect } from "../../hooks/useAtomEffect"; import { ResolvedEndpointDefinition, ResolvedError, ResolvedTypeDefinition } from "../../resolver/types"; import { ApiPageDescription } from "../ApiPageDescription"; import { JsonPropertyPath } from "../examples/JsonPropertyPath"; diff --git a/packages/ui/app/src/api-playground/PlaygroundContext.tsx b/packages/ui/app/src/api-playground/PlaygroundContext.tsx index 741f2a968f..20693f7fda 100644 --- a/packages/ui/app/src/api-playground/PlaygroundContext.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundContext.tsx @@ -8,6 +8,7 @@ import useSWR from "swr"; import urljoin from "url-join"; import { useCallbackOne as useStableCallback } from "use-memo-one"; import { capturePosthogEvent } from "../analytics/posthog"; +import { useAtomEffect } from "../atoms"; import { APIS, FLATTENED_APIS_ATOM } from "../atoms/apis"; import { useBasePath } from "../atoms/navigation"; import { @@ -16,7 +17,6 @@ import { useInitPlaygroundRouter, useOpenPlayground, } from "../atoms/playground"; -import { useAtomEffect } from "../hooks/useAtomEffect"; import { ResolvedApiDefinition, ResolvedRootPackage, isEndpoint, isWebSocket } from "../resolver/types"; import { getInitialEndpointRequestFormStateWithExample } from "./PlaygroundDrawer"; diff --git a/packages/ui/app/src/atoms/hooks/index.ts b/packages/ui/app/src/atoms/hooks/index.ts new file mode 100644 index 0000000000..7d9c5ca7c6 --- /dev/null +++ b/packages/ui/app/src/atoms/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useAtomEffect"; diff --git a/packages/ui/app/src/hooks/useAtomEffect.ts b/packages/ui/app/src/atoms/hooks/useAtomEffect.ts similarity index 100% rename from packages/ui/app/src/hooks/useAtomEffect.ts rename to packages/ui/app/src/atoms/hooks/useAtomEffect.ts diff --git a/packages/ui/app/src/atoms/index.ts b/packages/ui/app/src/atoms/index.ts new file mode 100644 index 0000000000..c2da53d7fc --- /dev/null +++ b/packages/ui/app/src/atoms/index.ts @@ -0,0 +1,2 @@ +export * from "./hooks"; +export * from "./utils"; diff --git a/packages/ui/app/src/atoms/playground.ts b/packages/ui/app/src/atoms/playground.ts index 42dbe1ad4b..9498786522 100644 --- a/packages/ui/app/src/atoms/playground.ts +++ b/packages/ui/app/src/atoms/playground.ts @@ -4,9 +4,9 @@ import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { capturePosthogEvent } from "../analytics/posthog"; import { PlaygroundRequestFormState } from "../api-playground/types"; -import { useAtomEffect } from "../hooks/useAtomEffect"; import { APIS } from "./apis"; import { FEATURE_FLAGS_ATOM } from "./flags"; +import { useAtomEffect } from "./hooks"; import { BELOW_HEADER_HEIGHT_ATOM } from "./layout"; import { LOCATION_ATOM } from "./location"; import { NAVIGATION_NODES_ATOM } from "./navigation"; diff --git a/packages/ui/app/src/atoms/sidebar.ts b/packages/ui/app/src/atoms/sidebar.ts index c84b14dc73..fd0e80fd27 100644 --- a/packages/ui/app/src/atoms/sidebar.ts +++ b/packages/ui/app/src/atoms/sidebar.ts @@ -1,8 +1,8 @@ import { atom, useAtomValue, useSetAtom } from "jotai"; -import { useTheme } from "next-themes"; import { useCallback, useEffect } from "react"; import { DOCS_LAYOUT_ATOM } from "./layout"; import { CURRENT_NODE_ATOM, RESOLVED_PATH_ATOM, SIDEBAR_ROOT_NODE_ATOM } from "./navigation"; +import { useSetTheme, useTheme } from "./theme"; import { IS_MOBILE_SCREEN_ATOM, MOBILE_SIDEBAR_ENABLED_ATOM } from "./viewport"; export const SEARCH_DIALOG_OPEN_ATOM = atom(false); @@ -77,7 +77,8 @@ export const SIDEBAR_DISMISSABLE_ATOM = atom((get) => { export function useMessageHandler(): void { const openSearchDialog = useOpenSearchDialog(); const openMobileSidebar = useOpenMobileSidebar(); - const { resolvedTheme, setTheme } = useTheme(); + const resolvedTheme = useTheme(); + const setTheme = useSetTheme(); useEffect(() => { if (typeof window === "undefined") { return; diff --git a/packages/ui/app/src/atoms/theme.ts b/packages/ui/app/src/atoms/theme.ts new file mode 100644 index 0000000000..cbdc53ec94 --- /dev/null +++ b/packages/ui/app/src/atoms/theme.ts @@ -0,0 +1,194 @@ +import { noop } from "@fern-ui/core-utils"; +import { ColorsConfig } from "@fern-ui/fdr-utils"; +import { atom, useAtom, useAtomValue } from "jotai"; +import { atomWithRefresh } from "jotai/utils"; +import { createElement, memo } from "react"; +import { useCallbackOne } from "use-memo-one"; +import { z } from "zod"; +import { getThemeColor } from "../next-app/utils/getColorVariables"; +import { useAtomEffect } from "./hooks/useAtomEffect"; +import { atomWithStorageString } from "./utils/atomWithStorageString"; + +const STORAGE_KEY = "theme"; +const SYSTEM = "system" as const; +const SYSTEM_THEMES = ["light" as const, "dark" as const]; +const MEDIA = "(prefers-color-scheme: dark)"; + +type Theme = (typeof SYSTEM_THEMES)[number]; + +const SETTABLE_THEME_ATOM = atomWithStorageString(STORAGE_KEY, SYSTEM, { + validate: z.union([z.literal("system"), z.literal("light"), z.literal("dark")]), + getOnInit: true, +}); + +const IS_SYSTEM_THEME_ATOM = atom((get) => get(SETTABLE_THEME_ATOM) === SYSTEM); + +export const COLORS_ATOM = atom({ dark: undefined, light: undefined }); +export const AVAILABLE_THEMES_ATOM = atom((get) => getAvailableThemes(get(COLORS_ATOM))); + +export function useColors(): ColorsConfig { + return useAtomValue(COLORS_ATOM); +} + +export const THEME_SWITCH_ENABLED_ATOM = atom((get) => { + const availableThemes = get(AVAILABLE_THEMES_ATOM); + return availableThemes.length > 1; +}); + +export const THEME_ATOM = atomWithRefresh((get): Theme => { + const storedTheme = get(SETTABLE_THEME_ATOM); + const availableThemes = get(AVAILABLE_THEMES_ATOM); + if (storedTheme === SYSTEM) { + if (typeof window !== "undefined") { + return getSystemTheme(); + } + } else if (availableThemes.includes(storedTheme)) { + return storedTheme; + } + return availableThemes[0]; +}); + +export function useTheme(): Theme { + return useAtomValue(THEME_ATOM); +} + +export function useSetTheme(): (theme: Theme | typeof SYSTEM) => void { + return useAtom(SETTABLE_THEME_ATOM)[1]; +} + +export function useToggleTheme(): () => void { + const setTheme = useSetTheme(); + const theme = useTheme(); + return () => setTheme(theme === "dark" ? "light" : "dark"); +} + +export const THEME_BG_COLOR = atom((get) => { + const theme = get(THEME_ATOM); + const colors = get(COLORS_ATOM); + const config = colors[theme]; + if (config == null) { + return undefined; + } + return getThemeColor(config); +}); + +const disableAnimation = () => { + if (typeof document === "undefined") { + return noop; + } + + const css = document.createElement("style"); + css.appendChild( + document.createTextNode( + "*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}", + ), + ); + document.head.appendChild(css); + + return () => { + // Force restyle + (() => window.getComputedStyle(document.body))(); + + // Wait for next tick before removing + setTimeout(() => { + document.head.removeChild(css); + }, 1); + }; +}; + +export type AvailableThemes = [Theme] | [Theme, Theme]; +const getAvailableThemes = (colors: Partial = {}): AvailableThemes => { + if ((colors.dark != null && colors.light != null) || (colors.dark == null && colors.light == null)) { + return ["light", "dark"]; + } + return colors.dark != null ? ["dark"] : ["light"]; +}; + +const getSystemTheme = (e?: MediaQueryList | MediaQueryListEvent) => { + if (!e) { + e = window.matchMedia(MEDIA); + } + const isDark = e.matches; + const systemTheme = isDark ? "dark" : "light"; + return systemTheme; +}; + +export function useInitializeTheme(): void { + useAtomEffect( + useCallbackOne((get) => { + const enableAnimation = disableAnimation(); + const theme = get(THEME_ATOM); + const d = document.documentElement; + d.classList.remove(...SYSTEM_THEMES); + d.classList.add(theme); + d.style.colorScheme = theme; + enableAnimation(); + }, []), + ); + + useAtomEffect( + useCallbackOne((get, set) => { + const handleMediaQuery = () => { + if (get(IS_SYSTEM_THEME_ATOM)) { + set(THEME_ATOM); + } + }; + + const media = window.matchMedia(MEDIA); + + // Intentionally use deprecated listener methods to support iOS & old browsers + // eslint-disable-next-line deprecation/deprecation + media.addListener(handleMediaQuery); + handleMediaQuery(); + + // eslint-disable-next-line deprecation/deprecation + return () => media.removeListener(handleMediaQuery); + }, []), + ); +} + +// this script cannot reference any other code since it will be stringified to be executed in the browser +export const script = (themes: AvailableThemes): void => { + const el = document.documentElement; + + function updateDOM(theme: string) { + el.classList.remove("light", "dark"); + el.classList.add(theme); + el.style.colorScheme = theme; + } + + function getSystemTheme() { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + + if (themes.length === 1) { + updateDOM(themes[0]); + } else { + try { + const themeName = localStorage.getItem("theme") ?? themes[0]; + const isSystem = themes.length > 0 && themeName === "system"; + const theme = isSystem ? getSystemTheme() : themeName; + updateDOM(theme); + } catch { + // + } + } +}; + +// including the ThemeScript component will prevent a flash of unstyled content (FOUC) when the page loads +// by setting the theme from local storage before the page is rendered +export const ThemeScript = memo( + ({ nonce, colors }: { nonce?: string; colors?: ColorsConfig }) => { + const scriptArgs = JSON.stringify(getAvailableThemes(colors)); + + return createElement("script", { + suppressHydrationWarning: true, + nonce: typeof window === "undefined" ? nonce : "", + dangerouslySetInnerHTML: { __html: `(${script.toString()})(${scriptArgs})` }, + }); + }, + (prev, next) => + prev.nonce === next.nonce && getAvailableThemes(prev.colors).length === getAvailableThemes(next.colors).length, +); + +ThemeScript.displayName = "ThemeScript"; diff --git a/packages/ui/app/src/atoms/utils/atomWithStorageString.ts b/packages/ui/app/src/atoms/utils/atomWithStorageString.ts new file mode 100644 index 0000000000..8d1aad4437 --- /dev/null +++ b/packages/ui/app/src/atoms/utils/atomWithStorageString.ts @@ -0,0 +1,78 @@ +import { noop } from "@fern-ui/core-utils"; +import { atomWithStorage } from "jotai/utils"; +import { z } from "zod"; + +export function atomWithStorageString( + key: string, + value: VALUE, + { validate, getOnInit }: { validate?: z.ZodType; getOnInit?: boolean } = {}, +): ReturnType> { + return atomWithStorage( + key, + value, + { + getItem: (key, initialValue) => { + if (typeof window === "undefined") { + return initialValue; + } + + try { + const stored: string | null = window.localStorage.getItem(key); + if (stored == null) { + return initialValue; + } + if (validate) { + const parsed = validate.safeParse(stored); + if (parsed.success) { + return parsed.data; + } + } + } catch { + // ignore + } + return initialValue; + }, + setItem: (key, newValue) => { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem(key, newValue); + } catch { + // ignore + } + }, + removeItem: (key) => { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.removeItem(key); + } catch { + // ignore + } + }, + subscribe: (key, callback, initialValue) => { + if (typeof window === "undefined") { + return noop; + } + + const listener = (e: StorageEvent) => { + if (e.key === key && e.newValue !== e.oldValue) { + callback( + (validate != null ? validate.safeParse(e.newValue)?.data : (e.newValue as VALUE)) ?? + initialValue, + ); + } + }; + window.addEventListener("storage", listener); + return () => { + window.removeEventListener("storage", listener); + }; + }, + }, + { getOnInit }, + ); +} diff --git a/packages/ui/app/src/atoms/utils/index.ts b/packages/ui/app/src/atoms/utils/index.ts new file mode 100644 index 0000000000..655f924fb5 --- /dev/null +++ b/packages/ui/app/src/atoms/utils/index.ts @@ -0,0 +1 @@ +export * from "./atomWithStorageString"; diff --git a/packages/ui/app/src/contexts/docs-context/DocsContext.ts b/packages/ui/app/src/contexts/docs-context/DocsContext.ts index 818c55352d..cc583f40f9 100644 --- a/packages/ui/app/src/contexts/docs-context/DocsContext.ts +++ b/packages/ui/app/src/contexts/docs-context/DocsContext.ts @@ -1,14 +1,9 @@ import { Algolia, DocsV1Read, FdrAPI } from "@fern-api/fdr-sdk"; -import { ColorsConfig } from "@fern-ui/fdr-utils"; import React from "react"; export const DocsContext = React.createContext({ logoHeight: undefined, logoHref: undefined, - colors: { - dark: undefined, - light: undefined, - }, typography: undefined, css: undefined, files: {}, @@ -21,7 +16,6 @@ export const DocsContext = React.createContext({ export interface DocsContextValue { logoHeight: DocsV1Read.Height | undefined; logoHref: DocsV1Read.Url | undefined; - colors: ColorsConfig; typography: DocsV1Read.DocsTypographyConfigV2 | undefined; css: DocsV1Read.CssConfig | undefined; files: Record; diff --git a/packages/ui/app/src/contexts/docs-context/DocsContextProvider.tsx b/packages/ui/app/src/contexts/docs-context/DocsContextProvider.tsx index fe566be4f0..ee1dc304cc 100644 --- a/packages/ui/app/src/contexts/docs-context/DocsContextProvider.tsx +++ b/packages/ui/app/src/contexts/docs-context/DocsContextProvider.tsx @@ -1,14 +1,14 @@ import { DocsV1Read } from "@fern-api/fdr-sdk"; import { JsonLd } from "@fern-ui/next-seo"; import { useDeepCompareMemoize } from "@fern-ui/react-commons"; -import { useTheme } from "next-themes"; +import { useAtomValue } from "jotai"; import Head from "next/head"; import Script from "next/script"; import { PropsWithChildren, useCallback, useMemo } from "react"; import { CustomerAnalytics } from "../../analytics/CustomerAnalytics"; import { renderSegmentSnippet } from "../../analytics/segment"; +import { THEME_BG_COLOR } from "../../atoms/theme"; import { DocsPage } from "../../next-app/DocsPage"; -import { getThemeColor } from "../../next-app/utils/getColorVariables"; import { renderThemeStylesheet } from "../../next-app/utils/renderThemeStylesheet"; import { DocsContext } from "./DocsContext"; @@ -26,7 +26,7 @@ export const DocsContextProvider: React.FC = ({ child const navbarLinks = useDeepCompareMemoize(pageProps.navbarLinks); const apis = useDeepCompareMemoize(pageProps.apis); const analytics = useDeepCompareMemoize(pageProps.analytics); - const { resolvedTheme: theme } = useTheme(); + const themeBackgroundColor = useAtomValue(THEME_BG_COLOR); const { logoHeight, logoHref } = pageProps; const { domain, basePath } = pageProps.baseUrl; @@ -87,12 +87,7 @@ export const DocsContextProvider: React.FC = ({ child name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> - {theme === "light" && colors.light != null && ( - - )} - {theme === "dark" && colors.dark != null && ( - - )} + {themeBackgroundColor != null && } {/* We concatenate all global styles into a single instance, diff --git a/packages/ui/app/src/docs/BgImageGradient.tsx b/packages/ui/app/src/docs/BgImageGradient.tsx index d5148f1b87..cd19b7232c 100644 --- a/packages/ui/app/src/docs/BgImageGradient.tsx +++ b/packages/ui/app/src/docs/BgImageGradient.tsx @@ -1,6 +1,6 @@ import cn from "clsx"; import { FC } from "react"; -import { useDocsContext } from "../contexts/docs-context/useDocsContext"; +import { useColors } from "../atoms/theme"; export declare namespace BgImageGradient { export interface Props { @@ -9,7 +9,7 @@ export declare namespace BgImageGradient { } export const BgImageGradient: FC = ({ className }) => { - const { colors } = useDocsContext(); + const colors = useColors(); const darkBackground = colors.dark?.background; const lightBackground = colors.light?.background; const darkBackgroundImage = colors.dark?.backgroundImage; diff --git a/packages/ui/app/src/docs/Header.tsx b/packages/ui/app/src/docs/Header.tsx index 8db9e76544..9e0a11b64a 100644 --- a/packages/ui/app/src/docs/Header.tsx +++ b/packages/ui/app/src/docs/Header.tsx @@ -7,6 +7,7 @@ import { isEqual } from "lodash-es"; import { CSSProperties, PropsWithChildren, forwardRef, memo } from "react"; import { SHOW_SEARCH_BAR_IN_SIDEBAR_ATOM } from "../atoms/layout"; import { useOpenSearchDialog } from "../atoms/sidebar"; +import { useColors } from "../atoms/theme"; import { FernLinkButton } from "../components/FernLinkButton"; import { useDocsContext } from "../contexts/docs-context/useDocsContext"; import { SEARCH_BOX_MOUNTED } from "../search/algolia/SearchBox"; @@ -30,7 +31,7 @@ const UnmemoizedHeader = forwardRef = ({ className, ...props }) => { - const { resolvedTheme, setTheme } = useTheme(); +export const ThemeButton = memo(({ className, ...props }: ThemeButton.Props) => { + const resolvedTheme = useTheme(); + const toggleTheme = useToggleTheme(); const mounted = useMounted(); const IconToUse = mounted && resolvedTheme === "dark" ? MoonIcon : SunIcon; @@ -20,13 +23,13 @@ export const ThemeButton: React.FC = ({ className, ...props } { - setTheme(resolvedTheme === "dark" ? "light" : "dark"); - }} + onClick={toggleTheme} rounded={true} variant="minimal" intent="primary" icon={} /> ); -}; +}); + +ThemeButton.displayName = "ThemeButton"; diff --git a/packages/ui/app/src/docs/ThemeProvider.tsx b/packages/ui/app/src/docs/ThemeProvider.tsx deleted file mode 100644 index ff30774e4a..0000000000 --- a/packages/ui/app/src/docs/ThemeProvider.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { ColorsConfig } from "@fern-ui/fdr-utils"; -import { noop } from "lodash-es"; -import { ThemeProvider as NextThemeProvider, useTheme } from "next-themes"; -import { - PropsWithChildren, - ReactElement, - createContext, - memo, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; - -interface ThemeProviderProps { - colors?: ColorsConfig | undefined; -} - -function getThemeFromColors(colors: ColorsConfig | undefined): "dark" | "light" | "darkAndLight" { - return colors == null || (colors.dark != null && colors.light != null) - ? "darkAndLight" - : colors.dark != null - ? "dark" - : "light"; -} - -const ThemeMetaContext = createContext<(colors: ColorsConfig | undefined) => void>(noop); - -// this should only be invoked in the local-preview-bundle: -export const useSetThemeColors = (): ((colors: ColorsConfig | undefined) => void) => useContext(ThemeMetaContext); - -export const ThemeProvider = memo(({ colors, children }: PropsWithChildren): ReactElement => { - // NextThemeProvider is not stable, and needs to be included in `_app.tsx` to work properly. - // docs-bundle uses static props to pass in the colors, whereas local-preview-bundle uses a hook. - const [theme, setTheme] = useState(getThemeFromColors(colors)); - const updateColors = useCallback((colors: ColorsConfig | undefined) => { - setTheme(getThemeFromColors(colors)); - }, []); - - const themes = useMemo(() => (theme === "darkAndLight" || theme == null ? ["dark", "light"] : [theme]), [theme]); - - return ( - - - - {children} - - - ); -}); - -ThemeProvider.displayName = "ThemeProvider"; - -function CorruptedThemeHack() { - const { resolvedTheme: theme, themes, setTheme } = useTheme(); - useEffect(() => { - // this is a hack to ensure that the theme is always set to a valid value, even if localStorage is corrupted - if (theme == null || !themes.includes(theme)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - setTheme(themes.length === 1 ? themes[0]! : "system"); - } - }, [setTheme, theme, themes]); - return null; -} diff --git a/packages/ui/app/src/index.ts b/packages/ui/app/src/index.ts index 219df3997a..6ef90c82ae 100644 --- a/packages/ui/app/src/index.ts +++ b/packages/ui/app/src/index.ts @@ -4,7 +4,6 @@ export type { ProxyRequest, ProxyResponse } from "./api-playground/types"; export { DEFAULT_FEATURE_FLAGS } from "./atoms/flags"; export type { FeatureFlags } from "./atoms/flags"; export { LocalPreviewContextProvider } from "./contexts/LocalPreviewContext"; -export { useSetThemeColors } from "./docs/ThemeProvider"; export { setMdxBundler } from "./mdx/bundler"; export { getFrontmatter } from "./mdx/frontmatter"; export * from "./next-app/DocsPage"; diff --git a/packages/ui/app/src/next-app/DocsPage.tsx b/packages/ui/app/src/next-app/DocsPage.tsx index 0c952cbbe0..23b321f4e5 100644 --- a/packages/ui/app/src/next-app/DocsPage.tsx +++ b/packages/ui/app/src/next-app/DocsPage.tsx @@ -24,6 +24,7 @@ import { VERSIONS_ATOM, } from "../atoms/navigation"; import { useMessageHandler } from "../atoms/sidebar"; +import { COLORS_ATOM, useInitializeTheme } from "../atoms/theme"; import { FernUser } from "../auth"; import { DocsContextProvider } from "../contexts/docs-context/DocsContextProvider"; import { NavigationContextProvider } from "../contexts/navigation-context/NavigationContextProvider"; @@ -84,9 +85,6 @@ export declare namespace DocsPage { export function DocsPage(pageProps: DocsPage.Props): ReactElement | null { const { baseUrl, resolvedPath } = pageProps; - useConsoleMessage(); - useMessageHandler(); - // Note: only hydrate atoms here. useHydrateAtoms( [ @@ -94,6 +92,7 @@ export function DocsPage(pageProps: DocsPage.Props): ReactElement | null { [BASEPATH_ATOM, baseUrl?.basePath], [RESOLVED_PATH_ATOM, resolvedPath], [SLUG_ATOM, FernNavigation.Slug(resolvedPath?.fullSlug)], + [COLORS_ATOM, useDeepCompareMemoize(pageProps.colors)], [DOCS_LAYOUT_ATOM, useDeepCompareMemoize(pageProps.layout)], [SIDEBAR_ROOT_NODE_ATOM, useDeepCompareMemoize(pageProps.navigation.sidebar)], [FEATURE_FLAGS_ATOM, useDeepCompareMemoize(pageProps.featureFlags)], @@ -112,6 +111,10 @@ export function DocsPage(pageProps: DocsPage.Props): ReactElement | null { { dangerouslyForceHydrate: true }, ); + useConsoleMessage(); + useMessageHandler(); + useInitializeTheme(); + return ( diff --git a/packages/ui/app/src/next-app/NextApp.tsx b/packages/ui/app/src/next-app/NextApp.tsx index 4b7cee7df4..64b98bf28e 100644 --- a/packages/ui/app/src/next-app/NextApp.tsx +++ b/packages/ui/app/src/next-app/NextApp.tsx @@ -10,11 +10,11 @@ import { SWRConfig } from "swr"; import DatadogInit from "../analytics/datadog"; import { initializePosthog } from "../analytics/posthog"; import { store } from "../atoms/store"; +import { ThemeScript } from "../atoms/theme"; import { FernErrorBoundary } from "../components/FernErrorBoundary"; import { RouteListenerContextProvider } from "../contexts/useRouteListener"; import "../css/globals.scss"; import { NextNProgress } from "../docs/NProgress"; -import { ThemeProvider } from "../docs/ThemeProvider"; import { DocsPage } from "./DocsPage"; export function NextApp({ Component, pageProps, router }: AppProps): ReactElement { @@ -32,18 +32,17 @@ export function NextApp({ Component, pageProps, router }: AppProps + - - - - - - - - + + + + + + diff --git a/packages/ui/app/src/search/inkeep/InkeepChatButton.tsx b/packages/ui/app/src/search/inkeep/InkeepChatButton.tsx index e7c59c354f..12b7d32ae4 100644 --- a/packages/ui/app/src/search/inkeep/InkeepChatButton.tsx +++ b/packages/ui/app/src/search/inkeep/InkeepChatButton.tsx @@ -1,7 +1,7 @@ import { DocsV1Read } from "@fern-api/fdr-sdk"; import dynamic from "next/dynamic"; import { ReactElement } from "react"; -import { useDocsContext } from "../../contexts/docs-context/useDocsContext"; +import { useColors } from "../../atoms/theme"; import { DEFAULT_COLORS } from "../../next-app/utils/getColorVariables"; import useInkeepSettings from "./useInkeepSettings"; @@ -19,7 +19,7 @@ function toString(rgba: DocsV1Read.RgbaColor): string { export function InkeepChatButton(): ReactElement | null { const settings = useInkeepSettings(); - const { colors } = useDocsContext(); + const colors = useColors(); if (settings == null) { return null; diff --git a/packages/ui/app/src/search/inkeep/useInkeepSettings.ts b/packages/ui/app/src/search/inkeep/useInkeepSettings.ts index d22d08c066..900c4bf358 100644 --- a/packages/ui/app/src/search/inkeep/useInkeepSettings.ts +++ b/packages/ui/app/src/search/inkeep/useInkeepSettings.ts @@ -4,8 +4,8 @@ import type { InkeepSearchSettings, InkeepWidgetBaseSettings, } from "@inkeep/widgets"; -import { useTheme } from "next-themes"; import type { DeepReadonly } from "ts-essentials"; +import { useTheme } from "../../atoms/theme"; import { useSearchConfig } from "../../services/useSearchService"; const useInkeepSettings = (): @@ -16,7 +16,7 @@ const useInkeepSettings = (): modalSettings?: InkeepModalSettings; } | undefined => { - const { resolvedTheme: theme } = useTheme(); + const theme = useTheme(); const [searchConfig] = useSearchConfig(); if (!searchConfig.isAvailable || searchConfig.inkeep == null) { @@ -25,7 +25,6 @@ const useInkeepSettings = (): const baseSettings: DeepReadonly = { colorMode: { - enableSystem: theme == null, forcedColorMode: theme, }, theme: { diff --git a/packages/ui/app/src/sidebar/CollapseSidebarContext.tsx b/packages/ui/app/src/sidebar/CollapseSidebarContext.tsx index e763e24d57..37e47add07 100644 --- a/packages/ui/app/src/sidebar/CollapseSidebarContext.tsx +++ b/packages/ui/app/src/sidebar/CollapseSidebarContext.tsx @@ -13,9 +13,9 @@ import { useState, } from "react"; import { useCallbackOne } from "use-memo-one"; +import { useAtomEffect } from "../atoms"; import { CURRENT_NODE_ATOM, CURRENT_NODE_ID_ATOM, useSidebarNodes } from "../atoms/navigation"; import { useActiveValueListeners } from "../hooks/useActiveValueListeners"; -import { useAtomEffect } from "../hooks/useAtomEffect"; interface CollapseSidebarContextValue { expanded: FernNavigation.NodeId[]; diff --git a/packages/ui/app/src/sidebar/DismissableSidebar.tsx b/packages/ui/app/src/sidebar/DismissableSidebar.tsx index c89f6fb072..bdd0d775f6 100644 --- a/packages/ui/app/src/sidebar/DismissableSidebar.tsx +++ b/packages/ui/app/src/sidebar/DismissableSidebar.tsx @@ -3,9 +3,9 @@ import { AnimatePresence, motion } from "framer-motion"; import { useAtomValue, useSetAtom } from "jotai"; import { ReactElement, useRef } from "react"; import { useCallbackOne } from "use-memo-one"; +import { useAtomEffect } from "../atoms"; import { DESKTOP_SIDEBAR_OPEN_ATOM, DISMISSABLE_SIDEBAR_OPEN_ATOM, MOBILE_SIDEBAR_OPEN_ATOM } from "../atoms/sidebar"; import { IS_MOBILE_SCREEN_ATOM } from "../atoms/viewport"; -import { useAtomEffect } from "../hooks/useAtomEffect"; import { SidebarContainer } from "./SidebarContainer"; const SidebarContainerMotion = motion(SidebarContainer); diff --git a/packages/ui/app/src/sidebar/SidebarFixedItemsSection.tsx b/packages/ui/app/src/sidebar/SidebarFixedItemsSection.tsx index c769443ff1..6e878570dc 100644 --- a/packages/ui/app/src/sidebar/SidebarFixedItemsSection.tsx +++ b/packages/ui/app/src/sidebar/SidebarFixedItemsSection.tsx @@ -2,7 +2,7 @@ import cn from "clsx"; import { useAtomValue } from "jotai"; import { useMemo } from "react"; import { DOCS_LAYOUT_ATOM, SHOW_SEARCH_BAR_IN_SIDEBAR_ATOM } from "../atoms/layout"; -import { useDocsContext } from "../contexts/docs-context/useDocsContext"; +import { THEME_SWITCH_ENABLED_ATOM } from "../atoms/theme"; import { HeaderLogoSection } from "../docs/HeaderLogoSection"; import { ThemeButton } from "../docs/ThemeButton"; import { SidebarSearchBar } from "./SidebarSearchBar"; @@ -17,7 +17,7 @@ export declare namespace SidebarFixedItemsSection { export const SidebarFixedItemsSection: React.FC = ({ className, showBorder }) => { const showSearchBar = useAtomValue(SHOW_SEARCH_BAR_IN_SIDEBAR_ATOM); - const { colors } = useDocsContext(); + const themeSwitchEnabled = useAtomValue(THEME_SWITCH_ENABLED_ATOM); const layout = useAtomValue(DOCS_LAYOUT_ATOM); const searchBar = useMemo(() => { @@ -35,7 +35,7 @@ export const SidebarFixedItemsSection: React.FC const header = layout?.disableHeader && (
-
{colors.dark && colors.light && }
+
{themeSwitchEnabled && }
); diff --git a/packages/ui/app/src/sidebar/SidebarLink.tsx b/packages/ui/app/src/sidebar/SidebarLink.tsx index 57dfd7d1de..85ab9232bb 100644 --- a/packages/ui/app/src/sidebar/SidebarLink.tsx +++ b/packages/ui/app/src/sidebar/SidebarLink.tsx @@ -20,9 +20,9 @@ import { useRef, } from "react"; import { ChevronDown } from "react-feather"; +import { useAtomEffect } from "../atoms"; import { SIDEBAR_SCROLL_CONTAINER_ATOM, useCloseMobileSidebar } from "../atoms/sidebar"; import { FernLink } from "../components/FernLink"; -import { useAtomEffect } from "../hooks/useAtomEffect"; import { getRouteNodeWithAnchor } from "../util/anchor"; import { slugToHref } from "../util/slugToHref"; import { scrollToCenter } from "./utils"; diff --git a/packages/ui/app/src/themes/default/DefaultDocs.tsx b/packages/ui/app/src/themes/default/DefaultDocs.tsx index 1a6ec060f5..0636ec7786 100644 --- a/packages/ui/app/src/themes/default/DefaultDocs.tsx +++ b/packages/ui/app/src/themes/default/DefaultDocs.tsx @@ -1,10 +1,9 @@ import clsx from "clsx"; import { useAtomValue } from "jotai"; -import { useTheme } from "next-themes"; import { ReactElement, memo } from "react"; import { CONTENT_HEIGHT_ATOM, DOCS_LAYOUT_ATOM, HEADER_OFFSET_ATOM, SHOW_HEADER_ATOM } from "../../atoms/layout"; import { SIDEBAR_DISMISSABLE_ATOM } from "../../atoms/sidebar"; -import { useDocsContext } from "../../contexts/docs-context/useDocsContext"; +import { useColors, useTheme } from "../../atoms/theme"; import { DocsMainContent } from "../../docs/DocsMainContent"; import { Sidebar } from "../../sidebar/Sidebar"; import { HeaderContainer } from "./HeaderContainer"; @@ -26,11 +25,11 @@ const DefaultDocsStyle = () => { }; function UnmemoizedDefaultDocs(): ReactElement { - const { colors } = useDocsContext(); + const colors = useColors(); const layout = useAtomValue(DOCS_LAYOUT_ATOM); const showHeader = useAtomValue(SHOW_HEADER_ATOM); - const { resolvedTheme: theme = "light" } = useTheme(); - const isSidebarFixed = layout?.disableHeader || colors[theme as "light" | "dark"]?.sidebarBackground != null; + const theme = useTheme(); + const isSidebarFixed = layout?.disableHeader || colors[theme]?.sidebarBackground != null; const isSidebarDismissable = useAtomValue(SIDEBAR_DISMISSABLE_ATOM); diff --git a/packages/ui/app/src/themes/default/HeaderContainer.tsx b/packages/ui/app/src/themes/default/HeaderContainer.tsx index c8606a5c17..efff73c095 100644 --- a/packages/ui/app/src/themes/default/HeaderContainer.tsx +++ b/packages/ui/app/src/themes/default/HeaderContainer.tsx @@ -3,8 +3,8 @@ import { useAtomValue } from "jotai"; import { ReactElement, useCallback } from "react"; import { HAS_HORIZONTAL_TABS } from "../../atoms/layout"; import { useIsMobileSidebarOpen } from "../../atoms/sidebar"; +import { useColors } from "../../atoms/theme"; import { MOBILE_SIDEBAR_ENABLED_ATOM } from "../../atoms/viewport"; -import { useDocsContext } from "../../contexts/docs-context/useDocsContext"; import { BgImageGradient } from "../../docs/BgImageGradient"; import { Header } from "../../docs/Header"; import { HeaderTabs } from "../../docs/HeaderTabs"; @@ -15,7 +15,7 @@ interface HeaderContainerProps { } export function HeaderContainer({ className }: HeaderContainerProps): ReactElement { - const { colors } = useDocsContext(); + const colors = useColors(); const showHeaderTabs = useAtomValue(HAS_HORIZONTAL_TABS); const isScrolled = useIsScrolled(); const isMobileSidebarEnabled = useAtomValue(MOBILE_SIDEBAR_ENABLED_ATOM); diff --git a/packages/ui/local-preview-bundle/package.json b/packages/ui/local-preview-bundle/package.json index ba99a00b0b..46b51a84ef 100644 --- a/packages/ui/local-preview-bundle/package.json +++ b/packages/ui/local-preview-bundle/package.json @@ -38,7 +38,6 @@ "@fern-ui/components": "workspace:*", "@fern-ui/core-utils": "workspace:*", "@fern-ui/fdr-utils": "workspace:*", - "@fern-ui/react-commons": "workspace:*", "@fern-ui/ui": "workspace:*", "cssnano": "^6.0.3", "jsonpath": "^1.1.1", diff --git a/packages/ui/local-preview-bundle/src/pages/[[...slug]].tsx b/packages/ui/local-preview-bundle/src/pages/[[...slug]].tsx index 62ea23d385..2f1f92f51d 100644 --- a/packages/ui/local-preview-bundle/src/pages/[[...slug]].tsx +++ b/packages/ui/local-preview-bundle/src/pages/[[...slug]].tsx @@ -1,7 +1,6 @@ import type { DocsV2Read } from "@fern-api/fdr-sdk"; import { toast } from "@fern-ui/components"; -import { useDeepCompareEffect } from "@fern-ui/react-commons"; -import { DocsPage, useSetThemeColors } from "@fern-ui/ui"; +import { DocsPage } from "@fern-ui/ui"; import { useRouter } from "next/router"; import { ReactElement, useEffect, useRef, useState } from "react"; import ReconnectingWebSocket from "../utils/ReconnectingWebsocket"; @@ -131,11 +130,6 @@ export default function LocalPreviewDocs(): ReactElement { }; }, [docs, router]); - const setThemeColors = useSetThemeColors(); - useDeepCompareEffect(() => { - setThemeColors(docsProps?.colors); - }, [docsProps?.colors]); - if (docsProps == null) { return <>; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 010651ce97..e0399424d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1202,9 +1202,6 @@ importers: next-mdx-remote: specifier: ^5.0.0 version: 5.0.0(@types/react@18.3.1)(react@18.3.1) - next-themes: - specifier: ^0.3.0 - version: 0.3.0(react-dom@18.3.1)(react@18.3.1) nprogress: specifier: ^0.2.0 version: 0.2.0 @@ -2020,9 +2017,6 @@ importers: '@fern-ui/fdr-utils': specifier: workspace:* version: link:../../commons/fdr-utils - '@fern-ui/react-commons': - specifier: workspace:* - version: link:../../commons/react/react-commons '@fern-ui/ui': specifier: workspace:* version: link:../app @@ -22479,16 +22473,6 @@ packages: react: 18.3.1 dev: true - /next-themes@0.3.0(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==} - peerDependencies: - react: ^16.8 || ^17 || ^18 - react-dom: ^16.8 || ^17 || ^18 - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - dev: false - /next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}