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

feat(theme-mermaid): upgrade Mermaid to v10.4 - handle async rendering #9305

Merged
merged 2 commits into from
Sep 14, 2023
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
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"website/src/data/users.tsx",
"website/src/data/tweets.tsx",
"website/docusaurus.config.localized.json",
"website/_dogfooding/_pages tests/diagrams.mdx",
"*.xyz",
"*.docx",
"*.gitignore",
Expand Down
1 change: 1 addition & 0 deletions packages/docusaurus-theme-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,5 +107,6 @@ export {
export {
ErrorBoundaryTryAgainButton,
ErrorBoundaryError,
ErrorBoundaryErrorMessageFallback,
ErrorCauseBoundary,
} from './utils/errorBoundaryUtils';
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@
white-space: pre-wrap;
color: red;
}

.errorBoundaryFallback {
color: red;
padding: 0.55rem;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import React, {type ComponentProps} from 'react';
import Translate from '@docusaurus/Translate';
import {getErrorCausalChain} from '@docusaurus/utils-common';
import type {Props as ErrorProps} from '@theme/Error';
import styles from './errorBoundaryUtils.module.css';

export function ErrorBoundaryTryAgainButton(
Expand All @@ -23,6 +24,20 @@ export function ErrorBoundaryTryAgainButton(
</button>
);
}

// A very simple reusable ErrorBoundary fallback component
export function ErrorBoundaryErrorMessageFallback({
error,
tryAgain,
}: ErrorProps): JSX.Element {
return (
<div className={styles.errorBoundaryFallback}>
<p>{error.message}</p>
<ErrorBoundaryTryAgainButton onClick={tryAgain} />
</div>
);
}

export function ErrorBoundaryError({error}: {error: Error}): JSX.Element {
const causalChain = getErrorCausalChain(error);
const fullMessage = causalChain.map((e) => e.message).join('\n\nCause:\n');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@ import Translate from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import BrowserOnly from '@docusaurus/BrowserOnly';
import {
ErrorBoundaryTryAgainButton,
ErrorBoundaryErrorMessageFallback,
usePrismTheme,
} from '@docusaurus/theme-common';
import ErrorBoundary from '@docusaurus/ErrorBoundary';

import type {Props} from '@theme/Playground';
import type {Props as ErrorProps} from '@theme/Error';
import type {ThemeConfig} from '@docusaurus/theme-live-codeblock';

import styles from './styles.module.css';
Expand All @@ -34,23 +33,17 @@ function LivePreviewLoader() {
return <div>Loading...</div>;
}

function ErrorFallback({error, tryAgain}: ErrorProps): JSX.Element {
return (
<div className={styles.errorFallback}>
<p>{error.message}</p>
<ErrorBoundaryTryAgainButton onClick={tryAgain} />
</div>
);
}

function Preview() {
// No SSR for the live preview
// See https://github.com/facebook/docusaurus/issues/5747
return (
<BrowserOnly fallback={<LivePreviewLoader />}>
{() => (
<>
<ErrorBoundary fallback={(params) => <ErrorFallback {...params} />}>
<ErrorBoundary
fallback={(params) => (
<ErrorBoundaryErrorMessageFallback {...params} />
)}>
<LivePreview />
</ErrorBoundary>
<LiveError />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,3 @@
padding: 1rem;
background-color: var(--ifm-pre-background);
}

.errorFallback {
padding: 0.55rem;
}
2 changes: 1 addition & 1 deletion packages/docusaurus-theme-mermaid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@docusaurus/theme-common": "3.0.0-alpha.0",
"@docusaurus/types": "3.0.0-alpha.0",
"@docusaurus/utils-validation": "3.0.0-alpha.0",
"mermaid": "^9.4.3",
"mermaid": "^10.4.0",
"tslib": "^2.6.0"
},
"devDependencies": {
Expand Down
119 changes: 78 additions & 41 deletions packages/docusaurus-theme-mermaid/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
* LICENSE file in the root directory of this source tree.
*/

import {useMemo} from 'react';
import {useState, useEffect, useMemo, useRef} from 'react';
import {useColorMode, useThemeConfig} from '@docusaurus/theme-common';
import mermaid, {type MermaidConfig} from 'mermaid';
import mermaid from 'mermaid';
import type {RenderResult, MermaidConfig} from 'mermaid';
import type {ThemeConfig} from '@docusaurus/theme-mermaid';

// Stable className to allow users to easily target with CSS
Expand All @@ -30,48 +31,84 @@ export function useMermaidConfig(): MermaidConfig {
);
}

export function useMermaidSvg(
txt: string,
mermaidConfigParam?: MermaidConfig,
): string {
function useMermaidId(): string {
/*
Random client-only id, we don't care much but mermaid want an id so...
Note: Mermaid doesn't like values provided by Rect.useId() and throws
*/
// return useId(); // tried that, doesn't work ('#d:re:' is not a valid selector.)
return useRef(`mermaid-svg-${Math.round(Math.random() * 10000000)}`).current!;
}

async function renderMermaid({
id,
text,
config,
}: {
id: string;
text: string;
config: MermaidConfig;
}): Promise<RenderResult> {
/*
Mermaid API is really weird :s
It is a big mutable singleton with multiple config levels
Note: most recent API type definitions are missing

There are 2 kind of configs:

- siteConfig: some kind of global/protected shared config
you can only set with "initialize"

- config/currentConfig
the config the renderer will use
it is reset to siteConfig before each render
but it can be altered by the mermaid txt content itself through directives

To use a new mermaid config (on colorMode change for example) we should
update siteConfig, and it can only be done with initialize()
*/
mermaid.mermaidAPI.initialize(config);

try {
return await mermaid.render(id, text);
} catch (e) {
// Because Mermaid add a weird SVG/Message to the DOM on error
// https://github.com/mermaid-js/mermaid/issues/3205#issuecomment-1719620183
document.querySelector(`#d${id}`)?.remove();
throw e;
}
Comment on lines +72 to +79
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Note to future self, track if Mermaid fix their weird errorRender.

See mermaid-js/mermaid#3205 (comment)

}

export function useMermaidRenderResult({
text,
config: providedConfig,
}: {
text: string;
config?: MermaidConfig;
}): RenderResult | null {
const [result, setResult] = useState<RenderResult | null>(null);
const id = useMermaidId();

/*
For flexibility, we allow the hook to receive a custom Mermaid config
The user could inject a modified version of the default config for example
*/
const defaultMermaidConfig = useMermaidConfig();
const mermaidConfig = mermaidConfigParam ?? defaultMermaidConfig;

return useMemo(() => {
/*
Mermaid API is really weird :s
It is a big mutable singleton with multiple config levels
Note: most recent API type definitions are missing

There are 2 kind of configs:

- siteConfig: some kind of global/protected shared config
you can only set with "initialize"

- config/currentConfig
the config the renderer will use
it is reset to siteConfig before each render
but it can be altered by the mermaid txt content itself through directives

To use a new mermaid config (on colorMode change for example) we should
update siteConfig, and it can only be done with initialize()
*/
mermaid.mermaidAPI.initialize(mermaidConfig);

/*
Random client-only id, we don't care much about it
But mermaid want an id so...
*/
const mermaidId = `mermaid-svg-${Math.round(Math.random() * 10000000)}`;

/*
Not even documented: mermaid.render returns the svg string
Using the documented form is un-necessary
*/
return mermaid.render(mermaidId, txt);
}, [txt, mermaidConfig]);
const config = providedConfig ?? defaultMermaidConfig;

useEffect(() => {
renderMermaid({id, text, config})
// TODO maybe try to use Suspense here and throw the promise?
// See also https://github.com/pmndrs/suspend-react
.then(setResult)
.catch((e) => {
// Funky way to trigger parent React error boundary
// See https://twitter.com/sebastienlorber/status/1628340871899893768
setResult(() => {
throw e;
});
});
}, [id, text, config]);

return result;
}
42 changes: 34 additions & 8 deletions packages/docusaurus-theme-mermaid/src/theme/Mermaid/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,54 @@
* LICENSE file in the root directory of this source tree.
*/

import React from 'react';
import BrowserOnly from '@docusaurus/BrowserOnly';
import React, {useEffect, useRef} from 'react';
import type {ReactNode} from 'react';
import ErrorBoundary from '@docusaurus/ErrorBoundary';
import {ErrorBoundaryErrorMessageFallback} from '@docusaurus/theme-common';
import {
MermaidContainerClassName,
useMermaidSvg,
useMermaidRenderResult,
} from '@docusaurus/theme-mermaid/client';

import type {Props} from '@theme/Mermaid';
import type {RenderResult} from 'mermaid';

import styles from './styles.module.css';

function MermaidDiagram({value}: Props): JSX.Element {
const svg = useMermaidSvg(value);
function MermaidRenderResult({
renderResult,
}: {
renderResult: RenderResult;
}): JSX.Element {
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
const div = ref.current!;
renderResult.bindFunctions?.(div);
}, [renderResult]);

return (
<div
ref={ref}
className={`${MermaidContainerClassName} ${styles.container}`}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: svg}}
dangerouslySetInnerHTML={{__html: renderResult.svg}}
/>
);
}

function MermaidRenderer({value}: Props): ReactNode {
const renderResult = useMermaidRenderResult({text: value});
if (renderResult === null) {
return null;
}
return <MermaidRenderResult renderResult={renderResult} />;
}

export default function Mermaid(props: Props): JSX.Element {
return <BrowserOnly>{() => <MermaidDiagram {...props} />}</BrowserOnly>;
return (
<ErrorBoundary
fallback={(params) => <ErrorBoundaryErrorMessageFallback {...params} />}>
<MermaidRenderer {...props} />
</ErrorBoundary>
);
}