Skip to content

Commit

Permalink
Remove Recoil (#2674)
Browse files Browse the repository at this point in the history
  • Loading branch information
gregberge authored Jan 6, 2025
1 parent 46f63cb commit f4a90de
Show file tree
Hide file tree
Showing 11 changed files with 208 additions and 161 deletions.
5 changes: 5 additions & 0 deletions .changeset/pretty-penguins-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': patch
---

Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
Binary file modified bun.lockb
Binary file not shown.
1 change: 0 additions & 1 deletion packages/gitbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
93 changes: 46 additions & 47 deletions packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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];
};
Expand Down Expand Up @@ -42,18 +76,21 @@ export function DynamicTabs(
const { id, tabs, tabsBody, style } = props;

const hash = useHash();

const activeState = useRecoilValue(tabsActiveSelector({ id, tabs }));

// To avoid issue with hydration, we only use the state from recoil (which is loaded from localstorage),
const [tabsState, setTabsState] = useTabsState();
const activeState = useMemo(() => {
const input = { id, tabs };
return (
getTabBySelection(input, tabsState) ?? getTabByTitle(input, tabsState) ?? input.tabs[0]
);
}, [id, tabs, tabsState]);

// 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
Expand Down Expand Up @@ -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.
*/
Expand Down
7 changes: 4 additions & 3 deletions packages/gitbook/src/components/PageBody/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -60,7 +61,7 @@ export function PageHeader(props: {
)}
/>
)}
</>
</Fragment>
))}
</ol>
</nav>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client';

import React from 'react';
import { RecoilRoot } from 'recoil';

import { TranslateContext } from '@/intl/client';
import { TranslationLanguage } from '@/intl/translations';
Expand All @@ -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>;
}
35 changes: 13 additions & 22 deletions packages/gitbook/src/components/Search/SearchAskAnswer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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.
*/
Expand All @@ -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({
Expand All @@ -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 () => {
Expand All @@ -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')}>
Expand All @@ -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>
);
}
Expand Down
37 changes: 37 additions & 0 deletions packages/gitbook/src/components/Search/SearchAskContext.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit f4a90de

Please sign in to comment.