From d6db38709890d06cb2915f182d3af6009ab8f194 Mon Sep 17 00:00:00 2001 From: David Pang Date: Tue, 24 May 2022 21:27:18 -0400 Subject: [PATCH 1/5] fix(theme-classic): inconsistent code block wrapping --- .../src/hooks/useCodeWordWrap.ts | 34 ++++++++++- .../_pages tests/code-block-tests.mdx | 59 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts b/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts index 2bffde55c4cf..a911f897f001 100644 --- a/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts +++ b/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts @@ -17,6 +17,8 @@ export function useCodeWordWrap(): { const [isEnabled, setIsEnabled] = useState(false); const [isCodeScrollable, setIsCodeScrollable] = useState(false); const codeBlockRef = useRef(null); + const [mutationObserver, setMutationObserver] = + useState(null); const toggle = useCallback(() => { const codeElement = codeBlockRef.current!.querySelector('code')!; @@ -25,6 +27,7 @@ export function useCodeWordWrap(): { codeElement.removeAttribute('style'); } else { codeElement.style.whiteSpace = 'pre-wrap'; + codeElement.style.overflowWrap = 'anywhere'; } setIsEnabled((value) => !value); @@ -32,11 +35,20 @@ export function useCodeWordWrap(): { const updateCodeIsScrollable = useCallback(() => { const {scrollWidth, clientWidth} = codeBlockRef.current!; + // Allows code block to update scrollWidth and clientWidth after "hidden" + // attribute is removed + const hiddenAncestor = codeBlockRef.current?.closest('[hidden]'); + if (hiddenAncestor && mutationObserver) { + mutationObserver.observe(hiddenAncestor, { + attributes: true, + attributeFilter: ['hidden'], + }); + } const isScrollable = scrollWidth > clientWidth || codeBlockRef.current!.querySelector('code')!.hasAttribute('style'); setIsCodeScrollable(isScrollable); - }, [codeBlockRef]); + }, [codeBlockRef, mutationObserver]); useEffect(() => { updateCodeIsScrollable(); @@ -47,10 +59,28 @@ export function useCodeWordWrap(): { passive: true, }); + if (!mutationObserver) { + setMutationObserver( + new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'hidden' + ) { + updateCodeIsScrollable(); + } + }); + }), + ); + } + return () => { window.removeEventListener('resize', updateCodeIsScrollable); + if (mutationObserver) { + mutationObserver.disconnect(); + } }; - }, [updateCodeIsScrollable]); + }, [updateCodeIsScrollable, mutationObserver]); return {codeBlockRef, isEnabled, isCodeScrollable, toggle}; } diff --git a/website/_dogfooding/_pages tests/code-block-tests.mdx b/website/_dogfooding/_pages tests/code-block-tests.mdx index d6f7c0816c20..95eb4e393923 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 +mkdir 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]: # From 9eec1936f305144c8e47cbdb386536102188c15f Mon Sep 17 00:00:00 2001 From: David Pang Date: Wed, 25 May 2022 17:02:35 -0400 Subject: [PATCH 2/5] refactor(theme-classic): abstract mutation observer --- .../src/hooks/useCodeWordWrap.ts | 63 ++++++++++--------- .../src/hooks/useMutationObserver.ts | 33 ++++++++++ 2 files changed, 66 insertions(+), 30 deletions(-) create mode 100644 packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts diff --git a/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts b/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts index a911f897f001..35443a1632b9 100644 --- a/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts +++ b/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts @@ -4,9 +4,35 @@ * 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 {useDynamicCallback} from '../utils/reactUtils'; +import {useMutationObserver} from './useMutationObserver'; + +function useHiddenAttributeMutationObserver( + target: Element | undefined | null, + callback: () => void, +) { + const hiddenAttributeCallback = useDynamicCallback( + (mutations: MutationRecord[]) => { + mutations.forEach((mutation) => { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'hidden' + ) { + callback(); + } + }); + }, + ); + + useMutationObserver(target, hiddenAttributeCallback, { + attributes: true, + characterData: false, + childList: false, + subtree: false, + }); +} export function useCodeWordWrap(): { readonly codeBlockRef: RefObject; @@ -16,9 +42,8 @@ export function useCodeWordWrap(): { } { const [isEnabled, setIsEnabled] = useState(false); const [isCodeScrollable, setIsCodeScrollable] = useState(false); + const [ancestor, setAncestor] = useState(); const codeBlockRef = useRef(null); - const [mutationObserver, setMutationObserver] = - useState(null); const toggle = useCallback(() => { const codeElement = codeBlockRef.current!.querySelector('code')!; @@ -37,18 +62,14 @@ export function useCodeWordWrap(): { const {scrollWidth, clientWidth} = codeBlockRef.current!; // Allows code block to update scrollWidth and clientWidth after "hidden" // attribute is removed - const hiddenAncestor = codeBlockRef.current?.closest('[hidden]'); - if (hiddenAncestor && mutationObserver) { - mutationObserver.observe(hiddenAncestor, { - attributes: true, - attributeFilter: ['hidden'], - }); - } + setAncestor(codeBlockRef.current?.closest('[hidden]')); const isScrollable = scrollWidth > clientWidth || codeBlockRef.current!.querySelector('code')!.hasAttribute('style'); setIsCodeScrollable(isScrollable); - }, [codeBlockRef, mutationObserver]); + }, [codeBlockRef]); + + useHiddenAttributeMutationObserver(ancestor, updateCodeIsScrollable); useEffect(() => { updateCodeIsScrollable(); @@ -59,28 +80,10 @@ export function useCodeWordWrap(): { passive: true, }); - if (!mutationObserver) { - setMutationObserver( - new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if ( - mutation.type === 'attributes' && - mutation.attributeName === 'hidden' - ) { - updateCodeIsScrollable(); - } - }); - }), - ); - } - return () => { window.removeEventListener('resize', updateCodeIsScrollable); - if (mutationObserver) { - mutationObserver.disconnect(); - } }; - }, [updateCodeIsScrollable, mutationObserver]); + }, [updateCodeIsScrollable]); return {codeBlockRef, isEnabled, isCodeScrollable, toggle}; } 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..a3f4423ca7cf --- /dev/null +++ b/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts @@ -0,0 +1,33 @@ +/** + * 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 {useRef, useMemo, useEffect} from 'react'; + +export function useMutationObserver( + target: Element | undefined | null, + callback: (mutations: MutationRecord[]) => void, + options = { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }, +): void { + const mutationObserver = useRef( + new MutationObserver(callback), + ); + const memoOptions = useMemo(() => options, [options]); + + useEffect(() => { + const observer = mutationObserver.current; + + if (target) { + observer.observe(target, memoOptions); + } + + return () => observer.disconnect(); + }, [target, memoOptions]); +} From e2864bcfca230637c1f67074870417e9fc038f3d Mon Sep 17 00:00:00 2001 From: David Pang Date: Wed, 25 May 2022 21:01:25 -0400 Subject: [PATCH 3/5] fix: MutationObserver reference error --- .../src/hooks/useMutationObserver.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts b/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts index a3f4423ca7cf..983234328443 100644 --- a/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts +++ b/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ import {useRef, useMemo, useEffect} from 'react'; +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; export function useMutationObserver( target: Element | undefined | null, @@ -16,18 +17,22 @@ export function useMutationObserver( subtree: true, }, ): void { - const mutationObserver = useRef( - new MutationObserver(callback), + const mutationObserver = useRef( + ExecutionEnvironment.canUseDOM ? new MutationObserver(callback) : undefined, ); const memoOptions = useMemo(() => options, [options]); useEffect(() => { const observer = mutationObserver.current; - if (target) { + if (target && observer) { observer.observe(target, memoOptions); } - return () => observer.disconnect(); + return () => { + if (observer) { + observer.disconnect(); + } + }; }, [target, memoOptions]); } From 2eb5951720ef5ff3d9f43d19314721a212217e92 Mon Sep 17 00:00:00 2001 From: sebastienlorber Date: Thu, 2 Jun 2022 14:45:18 +0200 Subject: [PATCH 4/5] refactor --- .../contexts/navbarSecondaryMenu/content.tsx | 13 +---- .../src/hooks/useCodeWordWrap.ts | 47 +++++++++++------- .../src/hooks/useMutationObserver.ts | 49 ++++++++++--------- .../src/hooks/useShallowMemoObject.ts | 17 +++++++ .../_pages tests/code-block-tests.mdx | 2 +- 5 files changed, 75 insertions(+), 53 deletions(-) create mode 100644 packages/docusaurus-theme-common/src/hooks/useShallowMemoObject.ts diff --git a/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx b/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx index 1183362b2349..9c4b9f2ba5b9 100644 --- a/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx +++ b/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx @@ -9,11 +9,11 @@ import React, { useState, useContext, useEffect, - useMemo, type ReactNode, type ComponentType, } from 'react'; import {ReactContextError} from '../../utils/reactUtils'; +import useShallowMemoObject from '../../hooks/useShallowMemoObject'; // This context represents a "global layout store". A component (usually a // layout component) can request filling this store through @@ -61,15 +61,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 +85,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 35443a1632b9..35d645322355 100644 --- a/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts +++ b/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts @@ -6,14 +6,32 @@ */ import type {RefObject} from 'react'; import {useState, useCallback, useEffect, useRef} from 'react'; -import {useDynamicCallback} from '../utils/reactUtils'; import {useMutationObserver} from './useMutationObserver'; -function useHiddenAttributeMutationObserver( - target: Element | undefined | null, +// 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 hiddenAttributeCallback = useDynamicCallback( + 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 ( @@ -21,17 +39,17 @@ function useHiddenAttributeMutationObserver( mutation.attributeName === 'hidden' ) { callback(); + updateHiddenTabElement(); } }); }, + { + attributes: true, + characterData: false, + childList: false, + subtree: false, + }, ); - - useMutationObserver(target, hiddenAttributeCallback, { - attributes: true, - characterData: false, - childList: false, - subtree: false, - }); } export function useCodeWordWrap(): { @@ -42,7 +60,6 @@ export function useCodeWordWrap(): { } { const [isEnabled, setIsEnabled] = useState(false); const [isCodeScrollable, setIsCodeScrollable] = useState(false); - const [ancestor, setAncestor] = useState(); const codeBlockRef = useRef(null); const toggle = useCallback(() => { @@ -52,7 +69,6 @@ export function useCodeWordWrap(): { codeElement.removeAttribute('style'); } else { codeElement.style.whiteSpace = 'pre-wrap'; - codeElement.style.overflowWrap = 'anywhere'; } setIsEnabled((value) => !value); @@ -60,16 +76,13 @@ export function useCodeWordWrap(): { const updateCodeIsScrollable = useCallback(() => { const {scrollWidth, clientWidth} = codeBlockRef.current!; - // Allows code block to update scrollWidth and clientWidth after "hidden" - // attribute is removed - setAncestor(codeBlockRef.current?.closest('[hidden]')); const isScrollable = scrollWidth > clientWidth || codeBlockRef.current!.querySelector('code')!.hasAttribute('style'); setIsCodeScrollable(isScrollable); }, [codeBlockRef]); - useHiddenAttributeMutationObserver(ancestor, updateCodeIsScrollable); + useTabBecameVisibleCallback(codeBlockRef, updateCodeIsScrollable); useEffect(() => { updateCodeIsScrollable(); diff --git a/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts b/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts index 983234328443..8ce4e701dcfa 100644 --- a/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts +++ b/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts @@ -4,35 +4,36 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -import {useRef, useMemo, useEffect} from 'react'; -import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import {useEffect} from 'react'; +import {useDynamicCallback} from '../utils/reactUtils'; +import useShallowMemoObject from './useShallowMemoObject'; + +type Options = MutationObserverInit; + +const DefaultOptions: Options = { + attributes: true, + characterData: true, + childList: true, + subtree: true, +}; export function useMutationObserver( target: Element | undefined | null, - callback: (mutations: MutationRecord[]) => void, - options = { - attributes: true, - characterData: true, - childList: true, - subtree: true, - }, + callback: MutationCallback, + options: Options = DefaultOptions, ): void { - const mutationObserver = useRef( - ExecutionEnvironment.canUseDOM ? new MutationObserver(callback) : undefined, - ); - const memoOptions = useMemo(() => options, [options]); + const stableCallback = useDynamicCallback(callback); - useEffect(() => { - const observer = mutationObserver.current; + // 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); - if (target && observer) { - observer.observe(target, memoOptions); + useEffect(() => { + const observer = new MutationObserver(stableCallback); + if (target) { + observer.observe(target, stableOptions); } - - return () => { - if (observer) { - observer.disconnect(); - } - }; - }, [target, memoOptions]); + return () => observer.disconnect(); + }, [target, stableCallback, stableOptions]); } diff --git a/packages/docusaurus-theme-common/src/hooks/useShallowMemoObject.ts b/packages/docusaurus-theme-common/src/hooks/useShallowMemoObject.ts new file mode 100644 index 000000000000..ca8da1f4a60b --- /dev/null +++ b/packages/docusaurus-theme-common/src/hooks/useShallowMemoObject.ts @@ -0,0 +1,17 @@ +/** + * 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 {useMemo} from 'react'; + +export default function useShallowMemoObject(obj: O): O { + return useMemo( + () => obj, + // Is this safe? + // 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 95eb4e393923..44db91d49094 100644 --- a/website/_dogfooding/_pages tests/code-block-tests.mdx +++ b/website/_dogfooding/_pages tests/code-block-tests.mdx @@ -215,7 +215,7 @@ echo "hi" ```bash -mkdir this_will_test_whether_a_long_string_that_is_initially_hidden_will_have_the_option_to_wrap_when_made_visible +echo this will test whether a long string that is initially hidden will have the option to wrap when made visible ``` From df58da09e0b12d7d4e76f01bc023d2b4a15722a7 Mon Sep 17 00:00:00 2001 From: sebastienlorber Date: Thu, 2 Jun 2022 15:05:07 +0200 Subject: [PATCH 5/5] refactor --- .../contexts/navbarSecondaryMenu/content.tsx | 3 +- .../src/hooks/useMutationObserver.ts | 3 +- .../src/hooks/useShallowMemoObject.ts | 17 --------- .../src/utils/__tests__/reactUtils.test.ts | 36 ++++++++++++++++++- .../src/utils/reactUtils.tsx | 22 +++++++++++- 5 files changed, 58 insertions(+), 23 deletions(-) delete mode 100644 packages/docusaurus-theme-common/src/hooks/useShallowMemoObject.ts diff --git a/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx b/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx index 9c4b9f2ba5b9..6972947a81a9 100644 --- a/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx +++ b/packages/docusaurus-theme-common/src/contexts/navbarSecondaryMenu/content.tsx @@ -12,8 +12,7 @@ import React, { type ReactNode, type ComponentType, } from 'react'; -import {ReactContextError} from '../../utils/reactUtils'; -import useShallowMemoObject from '../../hooks/useShallowMemoObject'; +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 diff --git a/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts b/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts index 8ce4e701dcfa..fc11277a02b5 100644 --- a/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts +++ b/packages/docusaurus-theme-common/src/hooks/useMutationObserver.ts @@ -5,8 +5,7 @@ * LICENSE file in the root directory of this source tree. */ import {useEffect} from 'react'; -import {useDynamicCallback} from '../utils/reactUtils'; -import useShallowMemoObject from './useShallowMemoObject'; +import {useDynamicCallback, useShallowMemoObject} from '../utils/reactUtils'; type Options = MutationObserverInit; diff --git a/packages/docusaurus-theme-common/src/hooks/useShallowMemoObject.ts b/packages/docusaurus-theme-common/src/hooks/useShallowMemoObject.ts deleted file mode 100644 index ca8da1f4a60b..000000000000 --- a/packages/docusaurus-theme-common/src/hooks/useShallowMemoObject.ts +++ /dev/null @@ -1,17 +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 {useMemo} from 'react'; - -export default function useShallowMemoObject(obj: O): O { - return useMemo( - () => obj, - // Is this safe? - // eslint-disable-next-line react-hooks/exhaustive-deps - [...Object.keys(obj), ...Object.values(obj)], - ); -} 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)], + ); +}