mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Remove Recoil (#2674)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user