diff --git a/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx b/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx index 1183362b2349..6972947a81a9 100644 --- a/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx +++ b/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx @@ -9,11 +9,10 @@ import React, { useState, useContext, useEffect, - useMemo, type ReactNode, type ComponentType, } from 'react'; -import {ReactContextError} from '../../utils/reactUtils'; +import {ReactContextError, useShallowMemoObject} from '../../utils/reactUtils'; // This context represents a "global layout store". A component (usually a // layout component) can request filling this store through @@ -61,15 +60,6 @@ export function useNavbarSecondaryMenuContent(): Content { return value[0]; } -function useShallowMemoizedObject(obj: O) { - return useMemo( - () => obj, - // Is this safe? - // eslint-disable-next-line react-hooks/exhaustive-deps - [...Object.keys(obj), ...Object.values(obj)], - ); -} - /** * This component renders nothing by itself, but it fills the placeholder in the * generic secondary menu layout. This reduces coupling between the main layout @@ -94,7 +84,7 @@ export function NavbarSecondaryMenuFiller

({ const [, setContent] = context; // To avoid useless context re-renders, props are memoized shallowly - const memoizedProps = useShallowMemoizedObject(props); + const memoizedProps = useShallowMemoObject(props); useEffect(() => { // @ts-expect-error: this context is hard to type diff --git a/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts b/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts index 2bffde55c4cf..35d645322355 100644 --- a/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts +++ b/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts @@ -4,9 +4,53 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - import type {RefObject} from 'react'; import {useState, useCallback, useEffect, useRef} from 'react'; +import {useMutationObserver} from './useMutationObserver'; + +// Callback fires when the "hidden" attribute of a tabpanel changes +// See https://github.com/facebook/docusaurus/pull/7485 +function useTabBecameVisibleCallback( + codeBlockRef: RefObject, + callback: () => void, +) { + const [hiddenTabElement, setHiddenTabElement] = useState< + Element | null | undefined + >(); + + const updateHiddenTabElement = useCallback(() => { + // No need to observe non-hidden tabs + // + we want to force a re-render when a tab becomes visible + setHiddenTabElement( + codeBlockRef.current?.closest('[role=tabpanel][hidden]'), + ); + }, [codeBlockRef, setHiddenTabElement]); + + useEffect(() => { + updateHiddenTabElement(); + }, [updateHiddenTabElement]); + + useMutationObserver( + hiddenTabElement, + (mutations: MutationRecord[]) => { + mutations.forEach((mutation) => { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'hidden' + ) { + callback(); + updateHiddenTabElement(); + } + }); + }, + { + attributes: true, + characterData: false, + childList: false, + subtree: false, + }, + ); +} export function useCodeWordWrap(): { readonly codeBlockRef: RefObject; @@ -38,6 +82,8 @@ export function useCodeWordWrap(): { setIsCodeScrollable(isScrollable); }, [codeBlockRef]); + useTabBecameVisibleCallback(codeBlockRef, updateCodeIsScrollable); + useEffect(() => { updateCodeIsScrollable(); }, [isEnabled, updateCodeIsScrollable]); diff --git a/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts b/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts new file mode 100644 index 000000000000..fc11277a02b5 --- /dev/null +++ b/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts @@ -0,0 +1,38 @@ +/** + * 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 {useEffect} from 'react'; +import {useDynamicCallback, useShallowMemoObject} from '../utils/reactUtils'; + +type Options = MutationObserverInit; + +const DefaultOptions: Options = { + attributes: true, + characterData: true, + childList: true, + subtree: true, +}; + +export function useMutationObserver( + target: Element | undefined | null, + callback: MutationCallback, + options: Options = DefaultOptions, +): void { + const stableCallback = useDynamicCallback(callback); + + // MutationObserver options are not nested much + // so this should be to memo options in 99% + // TODO handle options.attributeFilter array + const stableOptions: Options = useShallowMemoObject(options); + + useEffect(() => { + const observer = new MutationObserver(stableCallback); + if (target) { + observer.observe(target, stableOptions); + } + return () => observer.disconnect(); + }, [target, stableCallback, stableOptions]); +} diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/reactUtils.test.ts b/packages/docusaurus-theme-common/src/utils/__tests__/reactUtils.test.ts index 3aaec1e34704..df6e985d01fa 100644 --- a/packages/docusaurus-theme-common/src/utils/__tests__/reactUtils.test.ts +++ b/packages/docusaurus-theme-common/src/utils/__tests__/reactUtils.test.ts @@ -6,7 +6,7 @@ */ import {renderHook} from '@testing-library/react-hooks'; -import {usePrevious} from '../reactUtils'; +import {usePrevious, useShallowMemoObject} from '../reactUtils'; describe('usePrevious', () => { it('returns the previous value of a variable', () => { @@ -20,3 +20,37 @@ describe('usePrevious', () => { expect(result.current).toBe(2); }); }); + +describe('useShallowMemoObject', () => { + it('can memoize object', () => { + const someObj = {hello: 'world'}; + const someArray = ['hello', 'world']; + + const obj1 = {a: 1, b: '2', someObj, someArray}; + const {result, rerender} = renderHook((val) => useShallowMemoObject(val), { + initialProps: obj1, + }); + expect(result.current).toBe(obj1); + + const obj2 = {a: 1, b: '2', someObj, someArray}; + rerender(obj2); + expect(result.current).toBe(obj1); + + const obj3 = {a: 1, b: '2', someObj, someArray}; + rerender(obj3); + expect(result.current).toBe(obj1); + + // Current implementation is basic and sensitive to order + const obj4 = {b: '2', a: 1, someObj, someArray}; + rerender(obj4); + expect(result.current).toBe(obj4); + + const obj5 = {b: '2', a: 1, someObj, someArray}; + rerender(obj5); + expect(result.current).toBe(obj4); + + const obj6 = {b: '2', a: 1, someObj: {...someObj}, someArray}; + rerender(obj6); + expect(result.current).toBe(obj6); + }); +}); diff --git a/packages/docusaurus-theme-common/src/utils/reactUtils.tsx b/packages/docusaurus-theme-common/src/utils/reactUtils.tsx index 4e0982807121..e03d3c8de609 100644 --- a/packages/docusaurus-theme-common/src/utils/reactUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/reactUtils.tsx @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import {useCallback, useEffect, useLayoutEffect, useRef} from 'react'; +import {useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; /** @@ -74,3 +74,23 @@ export class ReactContextError extends Error { } is called outside the <${providerName}>. ${additionalInfo ?? ''}`; } } + +/** + * Shallow-memoize an object + * + * This means the returned object will be the same as the previous render + * if the attribute names and identities did not change. + * + * This works for simple cases: when attributes are primitives or stable objects + * + * @param obj + */ +export function useShallowMemoObject(obj: O): O { + return useMemo( + () => obj, + // Is this safe? + // TODO make this implementation not order-dependent? + // eslint-disable-next-line react-hooks/exhaustive-deps + [...Object.keys(obj), ...Object.values(obj)], + ); +} diff --git a/website/_dogfooding/_pages tests/code-block-tests.mdx b/website/_dogfooding/_pages tests/code-block-tests.mdx index d6f7c0816c20..44db91d49094 100644 --- a/website/_dogfooding/_pages tests/code-block-tests.mdx +++ b/website/_dogfooding/_pages tests/code-block-tests.mdx @@ -1,5 +1,7 @@ import CodeBlock from '@theme/CodeBlock'; import BrowserWindow from '@site/src/components/BrowserWindow'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; # Code block tests @@ -190,3 +192,60 @@ function PageLayout(props) { ); } ``` + +## Code block wrapping tests + +[// spell-checker:disable]: # + +```bash +mkdir this_is_a_loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_string_to_test_code_block_wrapping +echo "this is a long string made up of many separate words that should be broken between words when possible" +curl https://docusaurus.io/tests/pages/code-block-tests +``` + + + + + +```bash +echo "hi" +``` + + + + +```bash +echo this will test whether a long string that is initially hidden will have the option to wrap when made visible +``` + + + + + +```bash +rm short_initially_hidden_string +``` + + + + + + + + +```bash +echo medium_length_string_will_have_the_option_to_wrap_after_window_resized_while_it_is_hidden +``` + + + + + +```bash +echo "short_initially_hidden_string" +``` + + + + +[// spell-checker:enable]: #