From 6d72ed944290f7e1d769ff57788a7e0641074192 Mon Sep 17 00:00:00 2001 From: saudademjj <128795969+saudademjj@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:07:35 +0800 Subject: [PATCH] fix(theme-mermaid): re-render diagrams when tabs become visible --- .../src/hooks/useCodeWordWrap.ts | 46 +------ .../src/hooks/useTabBecameVisibleCallback.ts | 53 ++++++++ .../docusaurus-theme-common/src/internal.ts | 1 + .../src/client/index.ts | 4 +- .../theme/Mermaid/__tests__/index.test.tsx | 116 ++++++++++++++++++ .../src/theme/Mermaid/index.tsx | 23 +++- 6 files changed, 193 insertions(+), 50 deletions(-) create mode 100644 packages/docusaurus-theme-common/src/hooks/useTabBecameVisibleCallback.ts create mode 100644 packages/docusaurus-theme-mermaid/src/theme/Mermaid/__tests__/index.test.tsx diff --git a/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts b/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts index df17460c46f6..132d9736c997 100644 --- a/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts +++ b/packages/docusaurus-theme-common/src/hooks/useCodeWordWrap.ts @@ -6,51 +6,7 @@ */ 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, - }, - ); -} +import {useTabBecameVisibleCallback} from './useTabBecameVisibleCallback'; export type WordWrap = { readonly codeBlockRef: RefObject; diff --git a/packages/docusaurus-theme-common/src/hooks/useTabBecameVisibleCallback.ts b/packages/docusaurus-theme-common/src/hooks/useTabBecameVisibleCallback.ts new file mode 100644 index 000000000000..4f288ff1caf7 --- /dev/null +++ b/packages/docusaurus-theme-common/src/hooks/useTabBecameVisibleCallback.ts @@ -0,0 +1,53 @@ +/** + * 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 type {RefObject} from 'react'; +import {useState, useCallback, useEffect} from 'react'; +import {useMutationObserver} from './useMutationObserver'; + +// Callback fires when the "hidden" attribute of a tabpanel changes. +export function useTabBecameVisibleCallback( + elementRef: RefObject, + callback: () => void, +): 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( + elementRef.current?.closest('[role=tabpanel][hidden]'), + ); + }, [elementRef, 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, + }, + ); +} diff --git a/packages/docusaurus-theme-common/src/internal.ts b/packages/docusaurus-theme-common/src/internal.ts index 02c5776173d1..3fa71279d33a 100644 --- a/packages/docusaurus-theme-common/src/internal.ts +++ b/packages/docusaurus-theme-common/src/internal.ts @@ -103,6 +103,7 @@ export {useDateTimeFormat} from './utils/IntlUtils'; export {useHideableNavbar} from './hooks/useHideableNavbar'; export {useLockBodyScroll} from './hooks/useLockBodyScroll'; export {useCodeWordWrap} from './hooks/useCodeWordWrap'; +export {useTabBecameVisibleCallback} from './hooks/useTabBecameVisibleCallback'; export {useBackToTopButton} from './hooks/useBackToTopButton'; export {useDocCardDescriptionCategoryItemsPlural} from './translations/docsTranslations'; diff --git a/packages/docusaurus-theme-mermaid/src/client/index.ts b/packages/docusaurus-theme-mermaid/src/client/index.ts index 51a143387e34..d061556b7dfb 100644 --- a/packages/docusaurus-theme-mermaid/src/client/index.ts +++ b/packages/docusaurus-theme-mermaid/src/client/index.ts @@ -89,9 +89,11 @@ async function renderMermaid({ export function useMermaidRenderResult({ text, config: providedConfig, + renderCounter = 0, }: { text: string; config?: MermaidConfig; + renderCounter?: number; }): RenderResult | null { const [result, setResult] = useState(null); const id = useMermaidId(); @@ -115,7 +117,7 @@ export function useMermaidRenderResult({ throw e; }); }); - }, [id, text, config]); + }, [id, text, config, renderCounter]); return result; } diff --git a/packages/docusaurus-theme-mermaid/src/theme/Mermaid/__tests__/index.test.tsx b/packages/docusaurus-theme-mermaid/src/theme/Mermaid/__tests__/index.test.tsx new file mode 100644 index 000000000000..af7c67e543fe --- /dev/null +++ b/packages/docusaurus-theme-mermaid/src/theme/Mermaid/__tests__/index.test.tsx @@ -0,0 +1,116 @@ +/** + * 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. + * + * @jest-environment jsdom + */ + +// Jest doesn't allow pragma below other comments. https://github.com/facebook/jest/issues/12573 +// eslint-disable-next-line header/header +import React from 'react'; +import {act, render, waitFor} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Mermaid from '../index'; + +jest.mock( + '@docusaurus/ErrorBoundary', + () => { + function MockErrorBoundary({children}: {children: React.ReactNode}) { + return <>{children}; + } + + return MockErrorBoundary; + }, + {virtual: true}, +); + +jest.mock('@docusaurus/theme-common', () => ({ + ...jest.requireActual('@docusaurus/theme-common'), + useColorMode: () => ({colorMode: 'light'}), + useThemeConfig: () => ({ + mermaid: { + theme: { + light: 'default', + dark: 'dark', + }, + options: {}, + }, + }), +})); + +jest.mock( + '@docusaurus/theme-common/internal', + () => ({ + ErrorBoundaryErrorMessageFallback: () => null, + useTabBecameVisibleCallback: + require('../../../../../docusaurus-theme-common/src/hooks/useTabBecameVisibleCallback') + .useTabBecameVisibleCallback, + }), + {virtual: true}, +); + +jest.mock( + '@docusaurus/theme-mermaid/client', + () => ({ + MermaidContainerClassName: 'mermaid', + useMermaidRenderResult: jest.fn( + ({renderCounter}: {renderCounter: number}) => ({ + svg: ``, + bindFunctions: undefined, + }), + ), + }), + {virtual: true}, +); + +const {useMermaidRenderResult: mockUseMermaidRenderResult} = jest.requireMock( + '@docusaurus/theme-mermaid/client', +) as { + useMermaidRenderResult: jest.Mock; +}; + +describe('Mermaid', () => { + beforeEach(() => { + mockUseMermaidRenderResult.mockClear(); + }); + + it('re-renders when a hidden tab becomes visible', async () => { + const tabPanel = document.createElement('div'); + tabPanel.setAttribute('role', 'tabpanel'); + tabPanel.setAttribute('hidden', ''); + document.body.appendChild(tabPanel); + + const {unmount} = render( b'} />, { + container: tabPanel, + }); + + expect(mockUseMermaidRenderResult).toHaveBeenCalledWith({ + text: 'graph LR\n a --> b', + renderCounter: 0, + }); + expect(tabPanel.querySelector('svg')).toHaveAttribute( + 'data-render-counter', + '0', + ); + + await act(async () => { + tabPanel.removeAttribute('hidden'); + }); + + await waitFor(() => { + expect(mockUseMermaidRenderResult).toHaveBeenCalledWith({ + text: 'graph LR\n a --> b', + renderCounter: 1, + }); + expect(tabPanel.querySelector('svg')).toHaveAttribute( + 'data-render-counter', + '1', + ); + }); + + unmount(); + tabPanel.remove(); + }); +}); diff --git a/packages/docusaurus-theme-mermaid/src/theme/Mermaid/index.tsx b/packages/docusaurus-theme-mermaid/src/theme/Mermaid/index.tsx index dd0fe66231d2..2460da1a3c00 100644 --- a/packages/docusaurus-theme-mermaid/src/theme/Mermaid/index.tsx +++ b/packages/docusaurus-theme-mermaid/src/theme/Mermaid/index.tsx @@ -5,9 +5,12 @@ * LICENSE file in the root directory of this source tree. */ -import React, {useEffect, useRef, type ReactNode} from 'react'; +import React, {useEffect, useRef, useState, type ReactNode} from 'react'; import ErrorBoundary from '@docusaurus/ErrorBoundary'; -import {ErrorBoundaryErrorMessageFallback} from '@docusaurus/theme-common'; +import { + ErrorBoundaryErrorMessageFallback, + useTabBecameVisibleCallback, +} from '@docusaurus/theme-common/internal'; import { MermaidContainerClassName, useMermaidRenderResult, @@ -19,11 +22,15 @@ import styles from './styles.module.css'; function MermaidRenderResult({ renderResult, + onTabBecameVisible, }: { renderResult: RenderResult; + onTabBecameVisible: () => void; }): ReactNode { const ref = useRef(null); + useTabBecameVisibleCallback(ref, onTabBecameVisible); + useEffect(() => { const div = ref.current!; renderResult.bindFunctions?.(div); @@ -40,11 +47,19 @@ function MermaidRenderResult({ } function MermaidRenderer({value}: Props): ReactNode { - const renderResult = useMermaidRenderResult({text: value}); + const [renderCounter, setRenderCounter] = useState(0); + const renderResult = useMermaidRenderResult({text: value, renderCounter}); if (renderResult === null) { return null; } - return ; + return ( + { + setRenderCounter((value) => value + 1); + }} + /> + ); } export default function Mermaid(props: Props): ReactNode {