refactor(core): replace useDocusaurusContext().isClient by useIsBrowser() (#5349)

* extract separate useIsClient() hook

* for consistency, rename to `useIsBrowser`

* useless return

* improve doc for BrowserOnly

* update snapshot

* polish
This commit is contained in:
Sébastien Lorber 2021-08-12 19:02:29 +02:00 committed by GitHub
parent 69b11a8546
commit 295e77cc09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 213 additions and 90 deletions

View file

@ -179,6 +179,10 @@ declare module '@docusaurus/useDocusaurusContext' {
export default function useDocusaurusContext(): DocusaurusContext;
}
declare module '@docusaurus/useIsBrowser' {
export default function useIsBrowser(): boolean;
}
declare module '@docusaurus/useBaseUrl' {
export type BaseUrlOptions = {
forcePrependBaseUrl?: boolean;

View file

@ -8,14 +8,14 @@
import React from 'react';
import clsx from 'clsx';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useIsBrowser from '@docusaurus/useIsBrowser';
import useThemeContext from '@theme/hooks/useThemeContext';
import type {Props} from '@theme/ThemedImage';
import styles from './styles.module.css';
const ThemedImage = (props: Props): JSX.Element => {
const {isClient} = useDocusaurusContext();
const isBrowser = useIsBrowser();
const {isDarkTheme} = useThemeContext();
const {sources, className, alt = '', ...propsRest} = props;
@ -23,7 +23,7 @@ const ThemedImage = (props: Props): JSX.Element => {
const clientThemes: SourceName[] = isDarkTheme ? ['dark'] : ['light'];
const renderedSourceNames: SourceName[] = isClient
const renderedSourceNames: SourceName[] = isBrowser
? clientThemes
: // We need to render both images on the server to avoid flash
// See https://github.com/facebook/docusaurus/pull/3730

View file

@ -8,7 +8,6 @@
import React, {ReactNode, useState, useCallback} from 'react';
import {MDXProvider} from '@mdx-js/react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import renderRoutes from '@docusaurus/renderRoutes';
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs-types';
import Layout from '@theme/Layout';
@ -37,7 +36,6 @@ function DocPageContent({
versionMetadata,
children,
}: DocPageContentProps): JSX.Element {
const {isClient} = useDocusaurusContext();
const {pluginId, version} = versionMetadata;
const sidebarName = currentDocRoute.sidebar;
@ -57,7 +55,6 @@ function DocPageContent({
return (
<Layout
key={`${isClient}`} // TODO seems suspicious
wrapperClassName={ThemeClassNames.wrapper.docPages}
pageClassName={ThemeClassNames.page.docPage}
searchMetadatas={{

View file

@ -17,7 +17,6 @@ import {useThemeConfig} from '@docusaurus/theme-common';
const Logo = (props: Props): JSX.Element => {
const {
siteConfig: {title},
isClient,
} = useDocusaurusContext();
const {
navbar: {title: navbarTitle, logo = {src: ''}},
@ -37,7 +36,6 @@ const Logo = (props: Props): JSX.Element => {
{...(logo.target && {target: logo.target})}>
{logo.src && (
<ThemedImage
key={`${isClient}`} // TODO seems suspicious
className={imageClassName}
sources={sources}
alt={logo.alt || navbarTitle || title}

View file

@ -8,14 +8,14 @@
import React from 'react';
import clsx from 'clsx';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useIsBrowser from '@docusaurus/useIsBrowser';
import useThemeContext from '@theme/hooks/useThemeContext';
import type {Props} from '@theme/ThemedImage';
import styles from './styles.module.css';
const ThemedImage = (props: Props): JSX.Element => {
const {isClient} = useDocusaurusContext();
const isBrowser = useIsBrowser();
const {isDarkTheme} = useThemeContext();
const {sources, className, alt = '', ...propsRest} = props;
@ -23,7 +23,7 @@ const ThemedImage = (props: Props): JSX.Element => {
const clientThemes: SourceName[] = isDarkTheme ? ['dark'] : ['light'];
const renderedSourceNames: SourceName[] = isClient
const renderedSourceNames: SourceName[] = isBrowser
? clientThemes
: // We need to render both images on the server to avoid flash
// See https://github.com/facebook/docusaurus/pull/3730

View file

@ -8,7 +8,7 @@
import React, {useState, useRef, memo, CSSProperties} from 'react';
import type {Props} from '@theme/Toggle';
import {useThemeConfig} from '@docusaurus/theme-common';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useIsBrowser from '@docusaurus/useIsBrowser';
import clsx from 'clsx';
import './styles.css';
@ -91,11 +91,11 @@ export default function (props: Props): JSX.Element {
switchConfig: {darkIcon, darkIconStyle, lightIcon, lightIconStyle},
},
} = useThemeConfig();
const {isClient} = useDocusaurusContext();
const isBrowser = useIsBrowser();
return (
<Toggle
disabled={!isClient}
disabled={!isBrowser}
icons={{
checked: <Dark icon={darkIcon} style={darkIconStyle} />,
unchecked: <Light icon={lightIcon} style={lightIconStyle} />,

View file

@ -47,10 +47,6 @@ function useWindowSize(): WindowSize {
});
useEffect(() => {
if (!ExecutionEnvironment.canUseDOM) {
return undefined;
}
function updateWindowSize() {
setWindowSize(getWindowSize());
}

View file

@ -6,7 +6,7 @@
*/
import React, {ComponentProps, ReactElement, useRef, useState} from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useIsBrowser from '@docusaurus/useIsBrowser';
import clsx from 'clsx';
import {useCollapsible, Collapsible} from '../Collapsible';
import styles from './styles.module.css';
@ -30,7 +30,7 @@ export type DetailsProps = {
} & ComponentProps<'details'>;
const Details = ({summary, children, ...props}: DetailsProps): JSX.Element => {
const {isClient} = useDocusaurusContext();
const isBrowser = useIsBrowser();
const detailsRef = useRef<HTMLDetailsElement>(null);
const {collapsed, setCollapsed} = useCollapsible({
@ -48,7 +48,7 @@ const Details = ({summary, children, ...props}: DetailsProps): JSX.Element => {
data-collapsed={collapsed}
className={clsx(
styles.details,
{[styles.isClient]: isClient},
{[styles.isBrowser]: isBrowser},
props.className,
)}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}

View file

@ -46,9 +46,9 @@ CSS variables, meant to be overriden by final theme
}
/* When JS disabled/failed to load: we use the open property for arrow animation: */
.details[open]:not(.isClient) > summary:before,
.details[open]:not(.isBrowser) > summary:before,
/* When JS works: we use the data-attribute for arrow animation */
.details[data-collapsed='false'].isClient > summary:before {
.details[data-collapsed='false'].isBrowser > summary:before {
transform: rotate(90deg);
}

View file

@ -14,7 +14,7 @@ import React, {
useContext,
createContext,
} from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useIsBrowser from '@docusaurus/useIsBrowser';
import {createStorageSlot} from './storageUtils';
import {useThemeConfig} from './useThemeConfig';
@ -39,10 +39,10 @@ type AnnouncementBarAPI = {
const useAnnouncementBarContextValue = (): AnnouncementBarAPI => {
const {announcementBar} = useThemeConfig();
const {isClient} = useDocusaurusContext();
const isBrowser = useIsBrowser();
const [isClosed, setClosed] = useState(() => {
return isClient
return isBrowser
? // On client navigation: init with localstorage value
isDismissedInStorage()
: // On server/hydration: always visible to prevent layout shifts (will be hidden with css if needed)

View file

@ -10,6 +10,7 @@ import {LiveProvider, LiveEditor, LiveError, LivePreview} from 'react-live';
import clsx from 'clsx';
import Translate from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useIsBrowser from '@docusaurus/useIsBrowser';
import usePrismTheme from '@theme/hooks/usePrismTheme';
import styles from './styles.module.css';
@ -51,8 +52,8 @@ function EditorWithHeader() {
}
export default function Playground({children, transformCode, ...props}) {
const isBrowser = useIsBrowser();
const {
isClient,
siteConfig: {
themeConfig: {
liveCodeBlock: {playgroundPosition},
@ -64,8 +65,8 @@ export default function Playground({children, transformCode, ...props}) {
return (
<div className={styles.playgroundContainer}>
<LiveProvider
key={isClient}
code={isClient ? children.replace(/\n$/, '') : ''}
key={isBrowser}
code={isBrowser ? children.replace(/\n$/, '') : ''}
transformCode={transformCode || ((code) => `${code};`)}
theme={prismTheme}
{...props}>

View file

@ -125,7 +125,10 @@ export interface DocusaurusContext {
globalData: Record<string, unknown>;
i18n: I18n;
codeTranslations: Record<string, string>;
isClient: boolean;
// Don't put mutable values here, to avoid triggering re-renders
// We could reconsider that choice if context selectors are implemented
// isBrowser: boolean; // Not here on purpose!
}
export interface Preset {

View file

@ -5,16 +5,12 @@
* LICENSE file in the root directory of this source tree.
*/
import React, {useEffect, useState} from 'react';
import React from 'react';
import routes from '@generated/routes';
import siteConfig from '@generated/docusaurus.config';
import globalData from '@generated/globalData';
import i18n from '@generated/i18n';
import codeTranslations from '@generated/codeTranslations';
import siteMetadata from '@generated/site-metadata';
import renderRoutes from './exports/renderRoutes';
import DocusaurusContext from './exports/context';
import {BrowserContextProvider} from './exports/browserContext';
import {DocusaurusContextProvider} from './exports/docusaurusContext';
import PendingNavigation from './PendingNavigation';
import BaseUrlIssueBanner from './baseUrlIssueBanner/BaseUrlIssueBanner';
import Root from '@theme/Root';
@ -22,29 +18,17 @@ import Root from '@theme/Root';
import './client-lifecycles-dispatcher';
function App(): JSX.Element {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return (
<DocusaurusContext.Provider
value={{
siteConfig,
siteMetadata,
globalData,
i18n,
codeTranslations,
isClient,
}}>
<Root>
<BaseUrlIssueBanner />
<PendingNavigation routes={routes}>
{renderRoutes(routes)}
</PendingNavigation>
</Root>
</DocusaurusContext.Provider>
<DocusaurusContextProvider>
<BrowserContextProvider>
<Root>
<BaseUrlIssueBanner />
<PendingNavigation routes={routes}>
{renderRoutes(routes)}
</PendingNavigation>
</Root>
</BrowserContextProvider>
</DocusaurusContextProvider>
);
}

View file

@ -6,8 +6,10 @@
*/
import React from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useIsBrowser from '@docusaurus/useIsBrowser';
// Similar comp to the one described here:
// https://www.joshwcomeau.com/react/the-perils-of-rehydration/#abstractions
function BrowserOnly({
children,
fallback,
@ -15,9 +17,9 @@ function BrowserOnly({
children?: () => JSX.Element;
fallback?: JSX.Element;
}): JSX.Element | null {
const {isClient} = useDocusaurusContext();
const isBrowser = useIsBrowser();
if (isClient && children != null) {
if (isBrowser && children != null) {
return <>{children()}</>;
}

View file

@ -0,0 +1,32 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {ReactNode, useEffect, useState} from 'react';
// Encapsulate the logic to avoid React hydration problems
// See https://www.joshwcomeau.com/react/the-perils-of-rehydration/
// On first client-side render, we need to render exactly as the server rendered
// isBrowser is set to true only after a successful hydration
// Note, isBrowser is not part of useDocusaurusContext() for perf reasons
// Using useDocusaurusContext() (much more common need) should not trigger re-rendering after a successful hydration
export const Context = React.createContext<boolean>(false);
export function BrowserContextProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const [isBrowser, setIsBrowser] = useState(false);
useEffect(() => {
setIsBrowser(true);
}, []);
return <Context.Provider value={isBrowser}>{children}</Context.Provider>;
}

View file

@ -0,0 +1,35 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {ReactNode} from 'react';
import {DocusaurusContext} from '@docusaurus/types';
import siteConfig from '@generated/docusaurus.config';
import globalData from '@generated/globalData';
import i18n from '@generated/i18n';
import codeTranslations from '@generated/codeTranslations';
import siteMetadata from '@generated/site-metadata';
// Static value on purpose: don't make it dynamic!
// Using context is still useful for testability reasons.
const contextValue: DocusaurusContext = {
siteConfig,
siteMetadata,
globalData,
i18n,
codeTranslations,
};
export const Context = React.createContext<DocusaurusContext>(contextValue);
export function DocusaurusContextProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}

View file

@ -6,16 +6,11 @@
*/
import {useContext} from 'react';
import context from './context';
import {Context} from './docusaurusContext';
import {DocusaurusContext} from '@docusaurus/types';
function useDocusaurusContext(): DocusaurusContext {
const docusaurusContext = useContext(context);
if (docusaurusContext === null) {
// should not happen normally
throw new Error('Docusaurus context not provided.');
}
return docusaurusContext;
return useContext(Context);
}
export default useDocusaurusContext;

View file

@ -5,7 +5,9 @@
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import {DocusaurusContext} from '@docusaurus/types';
import {useContext} from 'react';
import {Context} from './browserContext';
export default React.createContext<DocusaurusContext | null>(null);
export default function useIsBrowser(): boolean {
return useContext(Context);
}

View file

@ -10,14 +10,16 @@ Object {
"@docusaurus/Link": "../../../../client/exports/Link.tsx",
"@docusaurus/Noop": "../../../../client/exports/Noop.ts",
"@docusaurus/Translate": "../../../../client/exports/Translate.tsx",
"@docusaurus/browserContext": "../../../../client/exports/browserContext.tsx",
"@docusaurus/constants": "../../../../client/exports/constants.ts",
"@docusaurus/context": "../../../../client/exports/context.ts",
"@docusaurus/docusaurusContext": "../../../../client/exports/docusaurusContext.tsx",
"@docusaurus/isInternalUrl": "../../../../client/exports/isInternalUrl.ts",
"@docusaurus/renderRoutes": "../../../../client/exports/renderRoutes.ts",
"@docusaurus/router": "../../../../client/exports/router.ts",
"@docusaurus/useBaseUrl": "../../../../client/exports/useBaseUrl.ts",
"@docusaurus/useDocusaurusContext": "../../../../client/exports/useDocusaurusContext.ts",
"@docusaurus/useGlobalData": "../../../../client/exports/useGlobalData.ts",
"@docusaurus/useIsBrowser": "../../../../client/exports/useIsBrowser.ts",
"@generated": "../../../../../../..",
"@site": "",
"@theme-init/PluginThemeComponentOverridden": "pluginThemeFolder/PluginThemeComponentOverridden.js",
@ -51,13 +53,15 @@ Object {
"@docusaurus/Link": "../../client/exports/Link.tsx",
"@docusaurus/Noop": "../../client/exports/Noop.ts",
"@docusaurus/Translate": "../../client/exports/Translate.tsx",
"@docusaurus/browserContext": "../../client/exports/browserContext.tsx",
"@docusaurus/constants": "../../client/exports/constants.ts",
"@docusaurus/context": "../../client/exports/context.ts",
"@docusaurus/docusaurusContext": "../../client/exports/docusaurusContext.tsx",
"@docusaurus/isInternalUrl": "../../client/exports/isInternalUrl.ts",
"@docusaurus/renderRoutes": "../../client/exports/renderRoutes.ts",
"@docusaurus/router": "../../client/exports/router.ts",
"@docusaurus/useBaseUrl": "../../client/exports/useBaseUrl.ts",
"@docusaurus/useDocusaurusContext": "../../client/exports/useDocusaurusContext.ts",
"@docusaurus/useGlobalData": "../../client/exports/useGlobalData.ts",
"@docusaurus/useIsBrowser": "../../client/exports/useIsBrowser.ts",
}
`;

View file

@ -109,19 +109,56 @@ const Home = () => {
### `<BrowserOnly/>` {#browseronly}
The `<BrowserOnly>` component accepts a `children` prop, a render function which will not be executed during the pre-rendering phase of the build process. This is useful for hiding code that is only meant to run in the browsers (e.g. where the `window`/`document` objects are being accessed). To improve SEO, you can also provide fallback content using the `fallback` prop, which will be prerendered until in the build process and replaced with the client-side only contents when viewed in the browser.
The `<BrowserOnly>` component permits to render React components only in the browser, after the React app has hydrated.
```jsx {1,5-10}
:::tip
Use it for integrating with code that can't run in Node.js, because `window` or `document` objects are being accessed.
:::
#### Props {#browseronly-props}
- `children`: render function prop returning browser-only JSX. Will not be executed in Node.js
- `fallback` (optional): JSX to render on the server (Node.js) and until React hydration completes.
#### Example with code {#browseronly-example-code}
```jsx
// highlight-start
import BrowserOnly from '@docusaurus/BrowserOnly';
// highlight-end
const MyComponent = () => {
return (
<BrowserOnly
fallback={<div>The fallback content to display on prerendering</div>}>
// highlight-start
<BrowserOnly>
{() => {
// Something that should be excluded during build process prerendering.
<span>page url = {window.location.href}</span>;
}}
</BrowserOnly>
// highlight-end
);
};
```
#### Example with a library {#browseronly-example-library}
```jsx
// highlight-start
import BrowserOnly from '@docusaurus/BrowserOnly';
// highlight-end
const MyComponent = (props) => {
return (
// highlight-start
<BrowserOnly fallback={<div>Loading...</div>}>
{() => {
const LibComponent = require('some-lib').LibComponent;
return <LibComponent {...props} />;
}}
</BrowserOnly>
// highlight-end
);
};
```
@ -132,7 +169,7 @@ A simple interpolation component for text containing dynamic placeholders.
The placeholders will be replaced with the provided dynamic values and JSX elements of your choice (strings, links, styled elements...).
#### Props {#props}
#### Props {#interpolate-props}
- `children`: text containing interpolation placeholders like `{placeholderName}`
- `values`: object containing interpolation placeholder values
@ -175,7 +212,7 @@ Apart the `values` prop used for interpolation, it is **not possible to use vari
:::
#### Props {#props-1}
#### Props {#translate-props}
- `children`: untranslated string in the default site locale (can contain [interpolation placeholders](#interpolate))
- `id`: optional value to use as key in JSON translation files
@ -253,7 +290,6 @@ interface DocusaurusContext {
globalData: Record<string, unknown>;
i18n: I18n;
codeTranslations: Record<string, string>;
isClient: boolean;
}
```
@ -275,6 +311,34 @@ const MyComponent = () => {
};
```
### `useIsBrowser` {#useIsBrowser}
Returns `true` when the React app has successfully hydrated in the browser.
:::caution
Use this hook instead of `typeof windows !== 'undefined'` in React rendering logic.
The first client-side render output (in the browser) **must be exactly the same** as the server-side render output (Node.js).
Not following this rule can lead to unexpected hydration behaviors, as described in [The Perils of Rehydration](https://www.joshwcomeau.com/react/the-perils-of-rehydration/).
:::
Usage example:
```jsx
import React from 'react';
import useIsBrowser from '@docusaurus/useIsBrowser';
const MyComponent = () => {
// highlight-start
const isBrowser = useIsBrowser();
// highlight-end
return <div>{isBrowser ? 'Client' : 'Server'}</div>;
};
```
### `useBaseUrl` {#usebaseurl}
React hook to prepend your site `baseUrl` to a string.
@ -525,21 +589,27 @@ export default function Home() {
### `ExecutionEnvironment` {#executionenvironment}
A module which exposes a few boolean variables to check the current rendering environment. Useful if you want to only run certain code on client/server or need to write server-side rendering compatible code.
A module which exposes a few boolean variables to check the current rendering environment.
```jsx {2,5}
import React from 'react';
:::caution
For React rendering logic, use [`useIsBrowser()`](#useIsBrowser) or [`<BrowserOnly>`](#browseronly) instead.
:::
Example:
```jsx
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
const MyPage = () => {
const location = ExecutionEnvironment.canUseDOM ? window.location.href : null;
return <div>{location}</div>;
};
if (ExecutionEnvironment.canUseDOM) {
require('lib-that-only-works-client-side');
}
```
| Field | Description |
| --- | --- |
| `ExecutionEnvironment.canUseDOM` | `true` if on client, `false` if prerendering. |
| `ExecutionEnvironment.canUseDOM` | `true` if on client/browser, `false` on Node.js/prerendering. |
| `ExecutionEnvironment.canUseEventListeners` | `true` if on client and has `window.addEventListener`. |
| `ExecutionEnvironment.canUseIntersectionObserver` | `true` if on client and has `IntersectionObserver`. |
| `ExecutionEnvironment.canUseViewport` | `true` if on client and has `window.screen`. |