diff --git a/.changeset/pretty-penguins-carry.md b/.changeset/pretty-penguins-carry.md new file mode 100644 index 000000000..46e18f2ea --- /dev/null +++ b/.changeset/pretty-penguins-carry.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Fix two issues where pages would crash due Recoil not behaving correctly in RSC. diff --git a/bun.lockb b/bun.lockb index 70ae2be15..2a62fae6f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 7bd6bab3c..b4a570631 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -52,7 +52,6 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-hotkeys-hook": "^4.4.1", - "recoil": "^0.7.7", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.0", "remark-gfm": "^4.0.0", diff --git a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx index 80c2cfa0f..d0ec26549 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx @@ -1,11 +1,46 @@ 'use client'; -import React from 'react'; -import { atom, selectorFamily, useRecoilValue, useSetRecoilState } from 'recoil'; +import React, { useCallback, useMemo } from 'react'; import { useHash, useIsMounted } from '@/components/hooks'; import { ClassValue, tcls } from '@/lib/tailwind'; +interface TabsState { + activeIds: { + [tabsBlockId: string]: string; + }; + activeTitles: string[]; +} + +let globalTabsState: TabsState = (() => { + if (typeof localStorage === 'undefined') { + return { activeIds: {}, activeTitles: [] }; + } + + const stored = localStorage.getItem('@gitbook/tabsState'); + return stored ? (JSON.parse(stored) as TabsState) : { activeIds: {}, activeTitles: [] }; +})(); +const listeners = new Set<() => void>(); + +function useTabsState() { + const subscribe = useCallback((callback: () => void) => { + listeners.add(callback); + return () => listeners.delete(callback); + }, []); + + const getSnapshot = useCallback(() => globalTabsState, []); + + const setTabsState = (updater: (previous: TabsState) => TabsState) => { + globalTabsState = updater(globalTabsState); + if (typeof localStorage !== 'undefined') { + localStorage.setItem('@gitbook/tabsState', JSON.stringify(globalTabsState)); + } + listeners.forEach((listener) => listener()); + }; + const state = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + return [state, setTabsState] as const; +} + // How many titles are remembered: const TITLES_MAX = 5; @@ -14,7 +49,6 @@ export interface TabsItem { title: string; } -// https://github.com/facebookexperimental/Recoil/issues/629#issuecomment-914273925 type SelectorMapper = { [Property in keyof Type]: Type[Property]; }; @@ -42,18 +76,21 @@ export function DynamicTabs( const { id, tabs, tabsBody, style } = props; const hash = useHash(); + const [tabsState, setTabsState] = useTabsState(); + const activeState = useMemo(() => { + const input = { id, tabs }; + return ( + getTabBySelection(input, tabsState) ?? getTabByTitle(input, tabsState) ?? input.tabs[0] + ); + }, [id, tabs, tabsState]); - const activeState = useRecoilValue(tabsActiveSelector({ id, tabs })); - - // To avoid issue with hydration, we only use the state from recoil (which is loaded from localstorage), + // To avoid issue with hydration, we only use the state from localStorage // once the component has been mounted. // Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time // and get stuck into an inconsistent state. const mounted = useIsMounted(); const active = mounted ? activeState : tabs[0]; - const setTabsState = useSetRecoilState(tabsAtom); - /** * When clicking to select a tab, we: * - mark this specific ID as selected @@ -220,44 +257,6 @@ export function DynamicTabs( ); } -const tabsAtom = atom({ - key: 'tabsAtom', - default: { - activeIds: {}, - activeTitles: [], - }, - effects: [ - // Persist the state to local storage - ({ trigger, setSelf, onSet }) => { - if (typeof localStorage === 'undefined') { - return; - } - - const localStorageKey = '@gitbook/tabsState'; - if (trigger === 'get') { - const stored = localStorage.getItem(localStorageKey); - if (stored) { - setSelf(JSON.parse(stored)); - } - } - - onSet((newState) => { - localStorage.setItem(localStorageKey, JSON.stringify(newState)); - }); - }, - ], -}); - -const tabsActiveSelector = selectorFamily>({ - key: 'tabsActiveSelector', - get: - (input) => - ({ get }) => { - const state = get(tabsAtom); - return getTabBySelection(input, state) ?? getTabByTitle(input, state) ?? input.tabs[0]; - }, -}); - /** * Get the ID for a tab button. */ diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 1e81e8ec0..2aa3eaaed 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -1,5 +1,6 @@ import { RevisionPage, RevisionPageDocument } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; +import { Fragment } from 'react'; import { pageHref } from '@/lib/links'; import { AncestorRevisionPage } from '@/lib/pages'; @@ -27,8 +28,8 @@ export function PageHeader(props: { diff --git a/packages/gitbook/src/components/RootLayout/ClientContexts.tsx b/packages/gitbook/src/components/RootLayout/ClientContexts.tsx index f2b1ce7da..153166047 100644 --- a/packages/gitbook/src/components/RootLayout/ClientContexts.tsx +++ b/packages/gitbook/src/components/RootLayout/ClientContexts.tsx @@ -1,7 +1,6 @@ 'use client'; import React from 'react'; -import { RecoilRoot } from 'recoil'; import { TranslateContext } from '@/intl/client'; import { TranslationLanguage } from '@/intl/translations'; @@ -12,9 +11,5 @@ export function ClientContexts(props: { }) { const { children, language } = props; - return ( - - {children} - - ); + return {children}; } diff --git a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx index ef9f68c82..018575132 100644 --- a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx +++ b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx @@ -2,7 +2,6 @@ import { Icon } from '@gitbook/icons'; import React from 'react'; -import { atom, useRecoilState } from 'recoil'; import { Loading } from '@/components/primitives'; import { useLanguage } from '@/intl/client'; @@ -16,8 +15,9 @@ import { AskAnswerResult, AskAnswerSource, streamAskQuestion } from './server-ac import { useSearch, useSearchLink } from './useSearch'; import { useTrackEvent } from '../Insights'; import { Link } from '../primitives'; +import { useSearchAskContext } from './SearchAskContext'; -type SearchState = +export type SearchAskState = | { type: 'answer'; answer: AskAnswerResult; @@ -29,15 +29,6 @@ type SearchState = type: 'loading'; }; -/** -- * Store the state of the answer in a global state so that it can be -- * accessed from anywhere to show a loading indicator. -- */ -export const searchAskState = atom({ - key: 'searchAskState', - default: null, -}); - /** * Fetch and render the answers to a question. */ @@ -47,13 +38,13 @@ export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: str const language = useLanguage(); const trackEvent = useTrackEvent(); const [, setSearchState] = useSearch(); - const [state, setState] = useRecoilState(searchAskState); + const [askState, setAskState] = useSearchAskContext(); const { organizationId, siteId, siteSpaceId } = pointer; React.useEffect(() => { let cancelled = false; - setState({ type: 'loading' }); + setAskState({ type: 'loading' }); (async () => { trackEvent({ @@ -73,14 +64,14 @@ export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: str return; } - setState({ type: 'answer', answer: chunk }); + setAskState({ type: 'answer', answer: chunk }); } })().catch(() => { if (cancelled) { return; } - setState({ type: 'error' }); + setAskState({ type: 'error' }); }); return () => { @@ -90,13 +81,13 @@ export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: str cancelled = true; } }; - }, [organizationId, siteId, siteSpaceId, query, setState, setSearchState]); + }, [organizationId, siteId, siteSpaceId, query, setAskState, setSearchState, trackEvent]); React.useEffect(() => { return () => { - setState(null); + setAskState(null); }; - }, [setState]); + }, [setAskState]); const loading = (
@@ -106,15 +97,15 @@ export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: str return (
- {state?.type === 'answer' ? ( + {askState?.type === 'answer' ? ( - + ) : null} - {state?.type === 'error' ? ( + {askState?.type === 'error' ? (
{t(language, 'search_ask_error')}
) : null} - {state?.type === 'loading' ? loading : null} + {askState?.type === 'loading' ? loading : null}
); } diff --git a/packages/gitbook/src/components/Search/SearchAskContext.tsx b/packages/gitbook/src/components/Search/SearchAskContext.tsx new file mode 100644 index 000000000..b3aeee315 --- /dev/null +++ b/packages/gitbook/src/components/Search/SearchAskContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, useMemo, useState } from 'react'; + +import { SearchAskState } from './SearchAskAnswer'; + +type SearchAskContextValue = [ + askState: SearchAskState | null, + setAskState: React.Dispatch>, +]; + +const SearchAskContext = createContext(undefined); + +/** + * Hook to manage the state of the search ask component. + */ +export function useSearchAskState(): SearchAskContextValue { + const [state, setState] = useState(null); + return useMemo(() => [state, setState], [state]); +} + +/** + * Provider for the search ask context. + */ +export function SearchAskProvider(props: { + children: React.ReactNode; + value: SearchAskContextValue; +}) { + const { children, value } = props; + return {children}; +} + +export function useSearchAskContext() { + const context = useContext(SearchAskContext); + if (!context) { + throw new Error('SearchAskContext is not available'); + } + return context; +} diff --git a/packages/gitbook/src/components/Search/SearchModal.tsx b/packages/gitbook/src/components/Search/SearchModal.tsx index 760513c5a..ca165e731 100644 --- a/packages/gitbook/src/components/Search/SearchModal.tsx +++ b/packages/gitbook/src/components/Search/SearchModal.tsx @@ -3,15 +3,15 @@ import { Icon } from '@gitbook/icons'; import { AnimatePresence, motion } from 'framer-motion'; import { useRouter } from 'next/navigation'; -import React from 'react'; +import React, { useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { useRecoilValue } from 'recoil'; import { tString, useLanguage } from '@/intl/client'; import { SiteContentPointer } from '@/lib/api'; import { tcls } from '@/lib/tailwind'; -import { SearchAskAnswer, searchAskState } from './SearchAskAnswer'; +import { SearchAskAnswer } from './SearchAskAnswer'; +import { SearchAskProvider, useSearchAskState } from './SearchAskContext'; import { SearchResults, SearchResultsRef } from './SearchResults'; import { SearchScopeToggle } from './SearchScopeToggle'; import { SearchState, UpdateSearchState, useSearch } from './useSearch'; @@ -31,7 +31,8 @@ interface SearchModalProps { */ export function SearchModal(props: SearchModalProps) { const [state, setSearchState] = useSearch(); - const askState = useRecoilValue(searchAskState); + const searchAsk = useSearchAskState(); + const [askState] = searchAsk; const router = useRouter(); useHotkeys( @@ -63,75 +64,77 @@ export function SearchModal(props: SearchModalProps) { }; return ( - - {state !== null ? ( - { - onClose(); - }} - > -
- - {askState?.type === 'loading' ? ( - - - - ) : null} - - -
-
- ) : null} -
+ + + {state !== null ? ( + { + onClose(); + }} + > +
+ + {askState?.type === 'loading' ? ( + + + + ) : null} + + +
+
+ ) : null} +
+
); } diff --git a/packages/react-openapi/package.json b/packages/react-openapi/package.json index 12dd99166..911b0dc78 100644 --- a/packages/react-openapi/package.json +++ b/packages/react-openapi/package.json @@ -24,8 +24,7 @@ "typescript": "^5.5.3" }, "peerDependencies": { - "react": "*", - "recoil": "^0.7.7" + "react": "*" }, "scripts": { "build": "tsc", diff --git a/packages/react-openapi/src/InteractiveSection.tsx b/packages/react-openapi/src/InteractiveSection.tsx index 37b19d247..077506bdb 100644 --- a/packages/react-openapi/src/InteractiveSection.tsx +++ b/packages/react-openapi/src/InteractiveSection.tsx @@ -1,8 +1,7 @@ 'use client'; import classNames from 'classnames'; -import React from 'react'; -import { atom, useRecoilState } from 'recoil'; +import React, { useCallback } from 'react'; interface InteractiveSectionTab { key: string; @@ -10,10 +9,29 @@ interface InteractiveSectionTab { body: React.ReactNode; } -const syncedTabsAtom = atom>({ - key: 'syncedTabState', - default: {}, -}); +let globalState: Record = {}; +const listeners = new Set<() => void>(); + +function useSyncedTabsGlobalState() { + const subscribe = useCallback((callback: () => void) => { + listeners.add(callback); + return () => listeners.delete(callback); + }, []); + + const getSnapshot = useCallback(() => globalState, []); + + const setSyncedTabs = useCallback( + (updater: (tabs: Record) => Record) => { + globalState = updater(globalState); + listeners.forEach((listener) => listener()); + }, + [], + ); + + const tabs = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + return [tabs, setSyncedTabs] as const; +} /** * To optimize rendering, most of the components are server-components, @@ -57,7 +75,7 @@ export function InteractiveSection(props: { toggleCloseIcon = '▼', stateKey, } = props; - const [syncedTabs, setSyncedTabs] = useRecoilState(syncedTabsAtom); + const [syncedTabs, setSyncedTabs] = useSyncedTabsGlobalState(); const tabFromState = stateKey && stateKey in syncedTabs ? tabs.find((tab) => tab.key === syncedTabs[stateKey])