Skip to content

Commit

Permalink
fix(theme-classic): inconsistent code block wrapping (#7485)
Browse files Browse the repository at this point in the history
Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com>
  • Loading branch information
dpang314 and slorber committed Jun 2, 2022
1 parent 7dd822b commit b215ad0
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 15 deletions.
Expand Up @@ -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
Expand Down Expand Up @@ -61,15 +60,6 @@ export function useNavbarSecondaryMenuContent(): Content {
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
* generic secondary menu layout. This reduces coupling between the main layout
Expand All @@ -94,7 +84,7 @@ export function NavbarSecondaryMenuFiller<P extends object>({
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
Expand Down
48 changes: 47 additions & 1 deletion packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts
Expand Up @@ -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<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(): {
readonly codeBlockRef: RefObject<HTMLPreElement>;
Expand Down Expand Up @@ -38,6 +82,8 @@ export function useCodeWordWrap(): {
setIsCodeScrollable(isScrollable);
}, [codeBlockRef]);

useTabBecameVisibleCallback(codeBlockRef, updateCodeIsScrollable);

useEffect(() => {
updateCodeIsScrollable();
}, [isEnabled, updateCodeIsScrollable]);
Expand Down
38 changes: 38 additions & 0 deletions 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]);
}
Expand Up @@ -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', () => {
Expand All @@ -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);
});
});
22 changes: 21 additions & 1 deletion packages/docusaurus-theme-common/src/utils/reactUtils.tsx
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<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)],
);
}
59 changes: 59 additions & 0 deletions 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

Expand Down Expand Up @@ -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]: #

0 comments on commit b215ad0

Please sign in to comment.