Remove Recoil (#2674)

This commit is contained in:
Greg Bergé
2025-01-06 16:06:59 +01:00
committed by GitHub
parent 46f63cbd55
commit f4a90defc3
11 changed files with 207 additions and 160 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
BIN
View File
Binary file not shown.
-1
View File
@@ -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",
@@ -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<Type> = {
[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<TabsState>({
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<TabsItem, SelectorMapper<TabsInput>>({
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.
*/
@@ -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: {
<nav>
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2')}>
{ancestors.map((breadcrumb, index) => (
<>
<li key={breadcrumb.id}>
<Fragment key={breadcrumb.id}>
<li>
<StyledLink
href={pageHref(pages, breadcrumb)}
style={tcls(
@@ -60,7 +61,7 @@ export function PageHeader(props: {
)}
/>
)}
</>
</Fragment>
))}
</ol>
</nav>
@@ -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 (
<RecoilRoot>
<TranslateContext.Provider value={language}>{children}</TranslateContext.Provider>
</RecoilRoot>
);
return <TranslateContext.Provider value={language}>{children}</TranslateContext.Provider>;
}
@@ -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<SearchState | null>({
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 = (
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
@@ -106,15 +97,15 @@ export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: str
return (
<div className={tcls('max-h-[60vh]', 'overflow-y-auto')}>
{state?.type === 'answer' ? (
{askState?.type === 'answer' ? (
<React.Suspense fallback={loading}>
<TransitionAnswerBody answer={state.answer} placeholder={loading} />
<TransitionAnswerBody answer={askState.answer} placeholder={loading} />
</React.Suspense>
) : null}
{state?.type === 'error' ? (
{askState?.type === 'error' ? (
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
) : null}
{state?.type === 'loading' ? loading : null}
{askState?.type === 'loading' ? loading : null}
</div>
);
}
@@ -0,0 +1,37 @@
import { createContext, useContext, useMemo, useState } from 'react';
import { SearchAskState } from './SearchAskAnswer';
type SearchAskContextValue = [
askState: SearchAskState | null,
setAskState: React.Dispatch<React.SetStateAction<SearchAskState | null>>,
];
const SearchAskContext = createContext<SearchAskContextValue | undefined>(undefined);
/**
* Hook to manage the state of the search ask component.
*/
export function useSearchAskState(): SearchAskContextValue {
const [state, setState] = useState<SearchAskState | null>(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 <SearchAskContext.Provider value={value}>{children}</SearchAskContext.Provider>;
}
export function useSearchAskContext() {
const context = useContext(SearchAskContext);
if (!context) {
throw new Error('SearchAskContext is not available');
}
return context;
}
@@ -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 (
<AnimatePresence>
{state !== null ? (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
transition={{
duration: 0.2,
delay: 0.1,
}}
role="dialog"
className={tcls(
'fixed',
'inset-0',
'bg-dark/4',
'backdrop-blur-2xl',
'z-30',
'px-4',
'pt-4',
'dark:bg-dark/8',
'md:pt-[min(8vh,6rem)]',
)}
onClick={() => {
onClose();
}}
>
<div className="scroll-nojump">
<AnimatePresence>
{askState?.type === 'loading' ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 1 }}
className={tcls(
'w-screen',
'h-screen',
'fixed',
'inset-0',
'z-10',
'pointer-events-none',
)}
>
<LoadingPane
gridStyle={['h-screen', 'aspect-auto', 'top-[-30%]']}
pulse
tile={96}
style={['grid']}
/>
</motion.div>
) : null}
</AnimatePresence>
<SearchModalBody
{...props}
state={state}
setSearchState={setSearchState}
onClose={onClose}
/>
</div>
</motion.div>
) : null}
</AnimatePresence>
<SearchAskProvider value={searchAsk}>
<AnimatePresence>
{state !== null ? (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
transition={{
duration: 0.2,
delay: 0.1,
}}
role="dialog"
className={tcls(
'fixed',
'inset-0',
'bg-dark/4',
'backdrop-blur-2xl',
'z-30',
'px-4',
'pt-4',
'dark:bg-dark/8',
'md:pt-[min(8vh,6rem)]',
)}
onClick={() => {
onClose();
}}
>
<div className="scroll-nojump">
<AnimatePresence>
{askState?.type === 'loading' ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 1 }}
className={tcls(
'w-screen',
'h-screen',
'fixed',
'inset-0',
'z-10',
'pointer-events-none',
)}
>
<LoadingPane
gridStyle={['h-screen', 'aspect-auto', 'top-[-30%]']}
pulse
tile={96}
style={['grid']}
/>
</motion.div>
) : null}
</AnimatePresence>
<SearchModalBody
{...props}
state={state}
setSearchState={setSearchState}
onClose={onClose}
/>
</div>
</motion.div>
) : null}
</AnimatePresence>
</SearchAskProvider>
);
}
+1 -2
View File
@@ -24,8 +24,7 @@
"typescript": "^5.5.3"
},
"peerDependencies": {
"react": "*",
"recoil": "^0.7.7"
"react": "*"
},
"scripts": {
"build": "tsc",
@@ -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<Record<string, string>>({
key: 'syncedTabState',
default: {},
});
let globalState: Record<string, string> = {};
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<string, string>) => Record<string, string>) => {
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])