fix(v2): Fix announcementBar layout shifts (#5040)

* Fix announcementBar layout shift

* useAnnouncementBar should return correct state after hydration

* refactor announcementBar => move utils to theme-common

* restore previous announcementBar

* typo
This commit is contained in:
Sébastien Lorber 2021-06-24 11:35:35 +02:00 committed by GitHub
parent 814455f88e
commit 9916a0b4a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 179 additions and 110 deletions

View file

@ -6,7 +6,7 @@
*/ */
import {DocusaurusContext, Plugin} from '@docusaurus/types'; import {DocusaurusContext, Plugin} from '@docusaurus/types';
import {ThemeConfig} from '@docusaurus/theme-common'; import type {ThemeConfig} from '@docusaurus/theme-common';
import {getTranslationFiles, translateThemeConfig} from './translations'; import {getTranslationFiles, translateThemeConfig} from './translations';
import path from 'path'; import path from 'path';
import Module from 'module'; import Module from 'module';
@ -24,7 +24,7 @@ const ContextReplacementPlugin = requireFromDocusaurusCore(
// Need to be inlined to prevent dark mode FOUC // Need to be inlined to prevent dark mode FOUC
// Make sure that the 'storageKey' is the same as the one in `/theme/hooks/useTheme.js` // Make sure that the 'storageKey' is the same as the one in `/theme/hooks/useTheme.js`
const storageKey = 'theme'; const ThemeStorageKey = 'theme';
const noFlashColorMode = ({defaultMode, respectPrefersColorScheme}) => { const noFlashColorMode = ({defaultMode, respectPrefersColorScheme}) => {
return `(function() { return `(function() {
var defaultMode = '${defaultMode}'; var defaultMode = '${defaultMode}';
@ -37,7 +37,7 @@ const noFlashColorMode = ({defaultMode, respectPrefersColorScheme}) => {
function getStoredTheme() { function getStoredTheme() {
var theme = null; var theme = null;
try { try {
theme = localStorage.getItem('${storageKey}'); theme = localStorage.getItem('${ThemeStorageKey}');
} catch (err) {} } catch (err) {}
return theme; return theme;
} }
@ -63,6 +63,26 @@ const noFlashColorMode = ({defaultMode, respectPrefersColorScheme}) => {
})();`; })();`;
}; };
// Duplicated constant. Unfortunately we can't import it from theme-common, as we need to support older nodejs versions without ESM support
// TODO: import from theme-common once we only support Node.js with ESM support
// + move all those announcementBar stuff there too
export const AnnouncementBarDismissStorageKey =
'docusaurus.announcement.dismiss';
const AnnouncementBarDismissDataAttribute =
'data-announcement-bar-initially-dismissed';
// We always render the announcement bar html on the server, to prevent layout shifts on React hydration
// The theme can use CSS + the data attribute to hide the announcement bar asap (before React hydration)
const AnnouncementBarInlineJavaScript = `
(function() {
function isDismissed() {
try {
return localStorage.getItem('${AnnouncementBarDismissStorageKey}') === 'true';
} catch (err) {}
return false;
}
document.documentElement.setAttribute('${AnnouncementBarDismissDataAttribute}', isDismissed());
})();`;
function getInfimaCSSFile(direction) { function getInfimaCSSFile(direction) {
return `infima/dist/css/default/default${ return `infima/dist/css/default/default${
direction === 'rtl' ? '-rtl' : '' direction === 'rtl' ? '-rtl' : ''
@ -82,7 +102,11 @@ export default function docusaurusThemeClassic(
i18n: {currentLocale, localeConfigs}, i18n: {currentLocale, localeConfigs},
} = context; } = context;
const themeConfig = (roughlyTypedThemeConfig || {}) as ThemeConfig; const themeConfig = (roughlyTypedThemeConfig || {}) as ThemeConfig;
const {colorMode, prism: {additionalLanguages = []} = {}} = themeConfig; const {
announcementBar,
colorMode,
prism: {additionalLanguages = []} = {},
} = themeConfig;
const {customCss} = options || {}; const {customCss} = options || {};
const {direction} = localeConfigs[currentLocale]; const {direction} = localeConfigs[currentLocale];
@ -178,7 +202,10 @@ export default function docusaurusThemeClassic(
preBodyTags: [ preBodyTags: [
{ {
tagName: 'script', tagName: 'script',
innerHTML: noFlashColorMode(colorMode), innerHTML: `
${noFlashColorMode(colorMode)}
${announcementBar ? AnnouncementBarInlineJavaScript : ''}
`,
}, },
], ],
}; };

View file

@ -7,17 +7,13 @@
import React from 'react'; import React from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import {useThemeConfig} from '@docusaurus/theme-common'; import {useThemeConfig, useAnnouncementBar} from '@docusaurus/theme-common';
import useUserPreferencesContext from '@theme/hooks/useUserPreferencesContext';
import {translate} from '@docusaurus/Translate'; import {translate} from '@docusaurus/Translate';
import styles from './styles.module.css'; import styles from './styles.module.css';
function AnnouncementBar(): JSX.Element | null { function AnnouncementBar(): JSX.Element | null {
const { const {isClosed, close} = useAnnouncementBar();
isAnnouncementBarClosed,
closeAnnouncementBar,
} = useUserPreferencesContext();
const {announcementBar} = useThemeConfig(); const {announcementBar} = useThemeConfig();
if (!announcementBar) { if (!announcementBar) {
@ -25,7 +21,8 @@ function AnnouncementBar(): JSX.Element | null {
} }
const {content, backgroundColor, textColor, isCloseable} = announcementBar; const {content, backgroundColor, textColor, isCloseable} = announcementBar;
if (!content || (isCloseable && isAnnouncementBarClosed)) {
if (!content || (isCloseable && isClosed)) {
return null; return null;
} }
@ -46,7 +43,7 @@ function AnnouncementBar(): JSX.Element | null {
<button <button
type="button" type="button"
className={clsx(styles.announcementBarClose, 'clean-btn')} className={clsx(styles.announcementBarClose, 'clean-btn')}
onClick={closeAnnouncementBar} onClick={close}
aria-label={translate({ aria-label={translate({
id: 'theme.AnnouncementBar.closeButtonAriaLabel', id: 'theme.AnnouncementBar.closeButtonAriaLabel',
message: 'Close', message: 'Close',

View file

@ -18,6 +18,10 @@
border-bottom: 1px solid var(--ifm-color-emphasis-100); border-bottom: 1px solid var(--ifm-color-emphasis-100);
} }
html[data-announcement-bar-initially-dismissed='true'] .announcementBar {
display: none;
}
@media print { @media print {
.announcementBar { .announcementBar {
display: none; display: none;

View file

@ -11,8 +11,8 @@ import {
useThemeConfig, useThemeConfig,
isSamePath, isSamePath,
usePrevious, usePrevious,
useAnnouncementBar,
} from '@docusaurus/theme-common'; } from '@docusaurus/theme-common';
import useUserPreferencesContext from '@theme/hooks/useUserPreferencesContext';
import useLockBodyScroll from '@theme/hooks/useLockBodyScroll'; import useLockBodyScroll from '@theme/hooks/useLockBodyScroll';
import useWindowSize, {windowSizes} from '@theme/hooks/useWindowSize'; import useWindowSize, {windowSizes} from '@theme/hooks/useWindowSize';
import useScrollPosition from '@theme/hooks/useScrollPosition'; import useScrollPosition from '@theme/hooks/useScrollPosition';
@ -198,12 +198,10 @@ function DocSidebarItemLink({
} }
function useShowAnnouncementBar() { function useShowAnnouncementBar() {
const {isAnnouncementBarClosed} = useUserPreferencesContext(); const {isClosed} = useAnnouncementBar();
const [showAnnouncementBar, setShowAnnouncementBar] = useState( const [showAnnouncementBar, setShowAnnouncementBar] = useState(!isClosed);
!isAnnouncementBarClosed,
);
useScrollPosition(({scrollY}) => { useScrollPosition(({scrollY}) => {
if (!isAnnouncementBarClosed) { if (!isClosed) {
setShowAnnouncementBar(scrollY === 0); setShowAnnouncementBar(scrollY === 0);
} }
}); });
@ -314,7 +312,7 @@ function DocSidebar({
navbar: {hideOnScroll}, navbar: {hideOnScroll},
hideableSidebar, hideableSidebar,
} = useThemeConfig(); } = useThemeConfig();
const {isAnnouncementBarClosed} = useUserPreferencesContext(); const {isClosed: isAnnouncementBarClosed} = useAnnouncementBar();
const { const {
showResponsiveSidebar, showResponsiveSidebar,

View file

@ -8,17 +8,22 @@
import React from 'react'; import React from 'react';
import ThemeProvider from '@theme/ThemeProvider'; import ThemeProvider from '@theme/ThemeProvider';
import UserPreferencesProvider from '@theme/UserPreferencesProvider'; import UserPreferencesProvider from '@theme/UserPreferencesProvider';
import {DocsPreferredVersionContextProvider} from '@docusaurus/theme-common'; import {
AnnouncementBarProvider,
DocsPreferredVersionContextProvider,
} from '@docusaurus/theme-common';
import type {Props} from '@theme/LayoutProviders'; import type {Props} from '@theme/LayoutProviders';
export default function LayoutProviders({children}: Props): JSX.Element { export default function LayoutProviders({children}: Props): JSX.Element {
return ( return (
<ThemeProvider> <ThemeProvider>
<UserPreferencesProvider> <AnnouncementBarProvider>
<DocsPreferredVersionContextProvider> <UserPreferencesProvider>
{children} <DocsPreferredVersionContextProvider>
</DocsPreferredVersionContextProvider> {children}
</UserPreferencesProvider> </DocsPreferredVersionContextProvider>
</UserPreferencesProvider>
</AnnouncementBarProvider>
</ThemeProvider> </ThemeProvider>
); );
} }

View file

@ -8,21 +8,16 @@
import React from 'react'; import React from 'react';
import useTabGroupChoice from '@theme/hooks/useTabGroupChoice'; import useTabGroupChoice from '@theme/hooks/useTabGroupChoice';
import useAnnouncementBar from '@theme/hooks/useAnnouncementBar';
import UserPreferencesContext from '@theme/UserPreferencesContext'; import UserPreferencesContext from '@theme/UserPreferencesContext';
import type {Props} from '@theme/UserPreferencesProvider'; import type {Props} from '@theme/UserPreferencesProvider';
function UserPreferencesProvider(props: Props): JSX.Element { function UserPreferencesProvider(props: Props): JSX.Element {
const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice(); const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice();
const {isAnnouncementBarClosed, closeAnnouncementBar} = useAnnouncementBar();
return ( return (
<UserPreferencesContext.Provider <UserPreferencesContext.Provider
value={{ value={{
tabGroupChoices, tabGroupChoices,
setTabGroupChoices, setTabGroupChoices,
isAnnouncementBarClosed,
closeAnnouncementBar,
}}> }}>
{props.children} {props.children}
</UserPreferencesContext.Provider> </UserPreferencesContext.Provider>

View file

@ -1,58 +0,0 @@
/**
* 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 {useState, useEffect, useCallback} from 'react';
import {useThemeConfig, createStorageSlot} from '@docusaurus/theme-common';
import type {useAnnouncementBarReturns} from '@theme/hooks/useAnnouncementBar';
const DismissStorage = createStorageSlot('docusaurus.announcement.dismiss');
const IdStorage = createStorageSlot('docusaurus.announcement.id');
const useAnnouncementBar = (): useAnnouncementBarReturns => {
const {announcementBar} = useThemeConfig();
const [isClosed, setClosed] = useState(true);
const handleClose = useCallback(() => {
DismissStorage.set('true');
setClosed(true);
}, []);
useEffect(() => {
if (!announcementBar) {
return;
}
const {id} = announcementBar;
let viewedId = IdStorage.get();
// retrocompatibility due to spelling mistake of default id
// see https://github.com/facebook/docusaurus/issues/3338
if (viewedId === 'annoucement-bar') {
viewedId = 'announcement-bar';
}
const isNewAnnouncement = id !== viewedId;
IdStorage.set(id);
if (isNewAnnouncement) {
DismissStorage.set('false');
}
if (isNewAnnouncement || DismissStorage.get() === 'false') {
setClosed(false);
}
}, []);
return {
isAnnouncementBarClosed: isClosed,
closeAnnouncementBar: handleClose,
};
};
export default useAnnouncementBar;

View file

@ -117,16 +117,6 @@ declare module '@theme/Heading' {
export const MainHeading: (props: Props) => JSX.Element; export const MainHeading: (props: Props) => JSX.Element;
} }
declare module '@theme/hooks/useAnnouncementBar' {
export type useAnnouncementBarReturns = {
readonly isAnnouncementBarClosed: boolean;
readonly closeAnnouncementBar: () => void;
};
const useAnnouncementBar: () => useAnnouncementBarReturns;
export default useAnnouncementBar;
}
declare module '@theme/hooks/useHideableNavbar' { declare module '@theme/hooks/useHideableNavbar' {
export type useHideableNavbarReturns = { export type useHideableNavbarReturns = {
readonly navbarRef: (node: HTMLElement | null) => void; readonly navbarRef: (node: HTMLElement | null) => void;
@ -214,8 +204,6 @@ declare module '@theme/hooks/useUserPreferencesContext' {
export type UserPreferencesContextProps = { export type UserPreferencesContextProps = {
tabGroupChoices: {readonly [groupId: string]: string}; tabGroupChoices: {readonly [groupId: string]: string};
setTabGroupChoices: (groupId: string, newChoice: string) => void; setTabGroupChoices: (groupId: string, newChoice: string) => void;
isAnnouncementBarClosed: boolean;
closeAnnouncementBar: () => void;
}; };
export default function useUserPreferencesContext(): UserPreferencesContextProps; export default function useUserPreferencesContext(): UserPreferencesContextProps;

View file

@ -45,3 +45,8 @@ export {
export {DocsPreferredVersionContextProvider} from './utils/docsPreferredVersion/DocsPreferredVersionProvider'; export {DocsPreferredVersionContextProvider} from './utils/docsPreferredVersion/DocsPreferredVersionProvider';
export {ThemeClassNames} from './utils/ThemeClassNames'; export {ThemeClassNames} from './utils/ThemeClassNames';
export {
AnnouncementBarProvider,
useAnnouncementBar,
} from './utils/announcementBarUtils';

View file

@ -0,0 +1,115 @@
/**
* 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, {
useState,
useEffect,
useCallback,
useMemo,
ReactNode,
useContext,
createContext,
} from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {createStorageSlot} from './storageUtils';
import {useThemeConfig} from './useThemeConfig';
export const AnnouncementBarDismissStorageKey =
'docusaurus.announcement.dismiss';
const AnnouncementBarIdStorageKey = 'docusaurus.announcement.id';
const AnnouncementBarDismissStorage = createStorageSlot(
AnnouncementBarDismissStorageKey,
);
const IdStorage = createStorageSlot(AnnouncementBarIdStorageKey);
const isDismissedInStorage = () =>
AnnouncementBarDismissStorage.get() === 'true';
const setDismissedInStorage = (bool: boolean) =>
AnnouncementBarDismissStorage.set(String(bool));
type AnnouncementBarAPI = {
readonly isClosed: boolean;
readonly close: () => void;
};
const useAnnouncementBarContextValue = (): AnnouncementBarAPI => {
const {announcementBar} = useThemeConfig();
const {isClient} = useDocusaurusContext();
const [isClosed, setClosed] = useState(() => {
return isClient
? // On client navigation: init with localstorage value
isDismissedInStorage()
: // On server/hydration: always visible to prevent layout shifts (will be hidden with css if needed)
false;
});
// Update state after hydration
useEffect(() => {
setClosed(isDismissedInStorage());
}, []);
const handleClose = useCallback(() => {
setDismissedInStorage(true);
setClosed(true);
}, []);
useEffect(() => {
if (!announcementBar) {
return;
}
const {id} = announcementBar;
let viewedId = IdStorage.get();
// retrocompatibility due to spelling mistake of default id
// see https://github.com/facebook/docusaurus/issues/3338
if (viewedId === 'annoucement-bar') {
viewedId = 'announcement-bar';
}
const isNewAnnouncement = id !== viewedId;
IdStorage.set(id);
if (isNewAnnouncement) {
setDismissedInStorage(false);
}
if (isNewAnnouncement || !isDismissedInStorage()) {
setClosed(false);
}
}, []);
return useMemo(() => {
return {
isClosed,
close: handleClose,
};
}, [isClosed]);
};
const AnnouncementBarContext = createContext<AnnouncementBarAPI | null>(null);
export const AnnouncementBarProvider = ({children}: {children: ReactNode}) => {
const value = useAnnouncementBarContextValue();
return (
<AnnouncementBarContext.Provider value={value}>
{children}
</AnnouncementBarContext.Provider>
);
};
export const useAnnouncementBar = (): AnnouncementBarAPI => {
const api = useContext(AnnouncementBarContext);
if (!api) {
throw new Error(
'useAnnouncementBar(): AnnouncementBar not found in React context: make sure to use the AnnouncementBarProvider on top of the tree',
);
}
return api;
};

View file

@ -305,17 +305,10 @@ const isVersioningDisabled = !!process.env.DISABLE_VERSIONING || isI18nStaging;
respectPrefersColorScheme: true, respectPrefersColorScheme: true,
}, },
announcementBar: { announcementBar: {
id: 'v1-new-domain', id: 'announcementBar-1', // Increment on change
content: content:
'➡️ Docusaurus v1 documentation has moved to <a target="_blank" rel="noopener noreferrer" href="https://v1.docusaurus.io/">v1.docusaurus.io</a>! 🔄', '⭐️ If you like Docusaurus, give it a star on <a target="_blank" rel="noopener noreferrer" href="https://github.com/facebook/docusaurus">GitHub</a>! ⭐',
}, },
/*
announcementBar: {
id: 'supportus',
content:
'⭐️ If you like Docusaurus, give it a star on <a target="_blank" rel="noopener noreferrer" href="https://github.com/facebook/docusaurus">GitHub</a>! ⭐️',
},
*/
prism: { prism: {
theme: require('prism-react-renderer/themes/github'), theme: require('prism-react-renderer/themes/github'),
darkTheme: require('prism-react-renderer/themes/dracula'), darkTheme: require('prism-react-renderer/themes/dracula'),