Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 6 commits into from Jun 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
@@ -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]: #
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, I didn't know this is a thing 🤔


```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]: #