fix(theme-classic): inconsistent code block wrapping (#7485)

Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com>
This commit is contained in:
dpang314 2022-06-02 09:56:54 -04:00 committed by GitHub
parent 7dd822becb
commit b215ad0e1b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 202 additions and 15 deletions

View file

@ -9,11 +9,10 @@ import React, {
useState, useState,
useContext, useContext,
useEffect, useEffect,
useMemo,
type ReactNode, type ReactNode,
type ComponentType, type ComponentType,
} from 'react'; } from 'react';
import {ReactContextError} from '../../utils/reactUtils'; import {ReactContextError, useShallowMemoObject} from '../../utils/reactUtils';
// This context represents a "global layout store". A component (usually a // This context represents a "global layout store". A component (usually a
// layout component) can request filling this store through // layout component) can request filling this store through
@ -61,15 +60,6 @@ export function useNavbarSecondaryMenuContent(): Content {
return value[0]; return value[0];
} }
function useShallowMemoizedObject<O>(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 * This component renders nothing by itself, but it fills the placeholder in the
* generic secondary menu layout. This reduces coupling between the main layout * generic secondary menu layout. This reduces coupling between the main layout
@ -94,7 +84,7 @@ export function NavbarSecondaryMenuFiller<P extends object>({
const [, setContent] = context; const [, setContent] = context;
// To avoid useless context re-renders, props are memoized shallowly // To avoid useless context re-renders, props are memoized shallowly
const memoizedProps = useShallowMemoizedObject(props); const memoizedProps = useShallowMemoObject(props);
useEffect(() => { useEffect(() => {
// @ts-expect-error: this context is hard to type // @ts-expect-error: this context is hard to type

View file

@ -4,9 +4,53 @@
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import type {RefObject} from 'react'; import type {RefObject} from 'react';
import {useState, useCallback, useEffect, useRef} 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<HTMLPreElement>,
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(): { export function useCodeWordWrap(): {
readonly codeBlockRef: RefObject<HTMLPreElement>; readonly codeBlockRef: RefObject<HTMLPreElement>;
@ -38,6 +82,8 @@ export function useCodeWordWrap(): {
setIsCodeScrollable(isScrollable); setIsCodeScrollable(isScrollable);
}, [codeBlockRef]); }, [codeBlockRef]);
useTabBecameVisibleCallback(codeBlockRef, updateCodeIsScrollable);
useEffect(() => { useEffect(() => {
updateCodeIsScrollable(); updateCodeIsScrollable();
}, [isEnabled, updateCodeIsScrollable]); }, [isEnabled, updateCodeIsScrollable]);

View file

@ -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]);
}

View file

@ -6,7 +6,7 @@
*/ */
import {renderHook} from '@testing-library/react-hooks'; import {renderHook} from '@testing-library/react-hooks';
import {usePrevious} from '../reactUtils'; import {usePrevious, useShallowMemoObject} from '../reactUtils';
describe('usePrevious', () => { describe('usePrevious', () => {
it('returns the previous value of a variable', () => { it('returns the previous value of a variable', () => {
@ -20,3 +20,37 @@ describe('usePrevious', () => {
expect(result.current).toBe(2); 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);
});
});

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree. * 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'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
/** /**
@ -74,3 +74,23 @@ export class ReactContextError extends Error {
} is called outside the <${providerName}>. ${additionalInfo ?? ''}`; } 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<O>(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)],
);
}

View file

@ -1,5 +1,7 @@
import CodeBlock from '@theme/CodeBlock'; import CodeBlock from '@theme/CodeBlock';
import BrowserWindow from '@site/src/components/BrowserWindow'; import BrowserWindow from '@site/src/components/BrowserWindow';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Code block tests # 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
```
<Tabs>
<TabItem value="short-tab-1" label="Short tab">
```bash
echo "hi"
```
</TabItem>
<TabItem value="long-tab" label="Long tab">
```bash
echo this will test whether a long string that is initially hidden will have the option to wrap when made visible
```
</TabItem>
<TabItem value="short-tab-2" label="Short tab">
```bash
rm short_initially_hidden_string
```
</TabItem>
</Tabs>
<Tabs>
<TabItem value="long-tab" label="Long tab">
```bash
echo medium_length_string_will_have_the_option_to_wrap_after_window_resized_while_it_is_hidden
```
</TabItem>
<TabItem value="short-tab" label="Short tab">
```bash
echo "short_initially_hidden_string"
```
</TabItem>
</Tabs>
[// spell-checker:enable]: #