Core & UI: Redesign useShiki & <DynamicCodeBlock /> to use React 19 hooks

This commit is contained in:
Fuma Nama
2025-08-20 00:24:33 +08:00
parent b99cf51a51
commit b109d06e66
3 changed files with 120 additions and 136 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-core': patch
'fumadocs-ui': patch
---
Redesign `useShiki` & `<DynamicCodeBlock />` to use React 19 hooks
+20 -101
View File
@@ -3,122 +3,41 @@ import {
type DependencyList,
type ReactNode,
use,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import {
_highlight,
_renderHighlight,
highlight,
type HighlightOptions,
} from './shiki';
import type { Root } from 'hast';
import { highlight, type HighlightOptions } from './shiki';
interface Task {
key: string;
aborted: boolean;
}
const promises: Record<string, Promise<ReactNode>> = {};
export function useShiki(
code: string,
{
withPrerenderScript = false,
loading,
...options
}: HighlightOptions & {
options: HighlightOptions & {
/**
* @deprecated no longer pre-rendered using scripts.
*/
withPrerenderScript?: boolean;
/**
* Displayed before highlighter is loaded.
*
* @deprecated use React `Suspense` fallback instead.
*/
loading?: ReactNode;
},
deps?: DependencyList,
): ReactNode {
const markupId = useId();
const key = useMemo(
() => (deps ? JSON.stringify(deps) : `${options.lang}:${code}`),
[code, deps, options.lang],
const id = useId();
const key = useMemo(() => {
const state = deps ? JSON.stringify(deps) : `${options.lang}:${code}`;
return `${id}:${state}`;
}, [code, deps, id, options.lang]);
return use(
(promises[key] ??= highlight(code, {
...options,
engine: options.engine ?? 'js',
})),
);
const shikiOptions: HighlightOptions = {
...options,
engine: options.engine ?? 'js',
};
const currentTask = useRef<Task | undefined>({
key,
aborted: false,
});
const [rendered, setRendered] = useState<ReactNode>(() => {
const element =
withPrerenderScript && typeof document !== 'undefined'
? document.querySelector(`[data-markup-id="${markupId}"]`)
: null;
const attr = element?.getAttribute('data-markup');
if (attr) {
const hast = JSON.parse(attr);
return renderHighlightWithMarkup(markupId, hast, shikiOptions, attr);
}
currentTask.current = undefined;
return loading;
});
useEffect(() => {
if (currentTask.current?.key === key) return;
if (currentTask.current) {
currentTask.current.aborted = true;
}
const task: Task = {
key,
aborted: false,
};
currentTask.current = task;
void highlight(code, shikiOptions).then((result) => {
if (!task.aborted) setRendered(result);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- listen for defined deps only
}, [key]);
if (typeof window === 'undefined') {
// return promise on server
return use(
_highlight(code, shikiOptions).then((tree) =>
renderHighlightWithMarkup(markupId, tree, shikiOptions),
),
);
}
return rendered;
}
function renderHighlightWithMarkup(
id: string,
tree: Root,
shikiOptions: HighlightOptions,
rawAttr?: string,
) {
const Pre = (shikiOptions.components?.pre ?? 'pre') as 'pre';
return _renderHighlight(tree, {
...shikiOptions,
components: {
...shikiOptions.components,
pre: (props) => (
<Pre
{...props}
data-markup-id={id}
data-markup={rawAttr ?? JSON.stringify(tree)}
/>
),
},
});
}
@@ -1,16 +1,49 @@
'use client';
import { CodeBlock, Pre } from '@/components/codeblock';
import { CodeBlock, type CodeBlockProps, Pre } from '@/components/codeblock';
import type {
HighlightOptions,
HighlightOptionsCommon,
HighlightOptionsThemes,
} from 'fumadocs-core/highlight';
import { useShiki } from 'fumadocs-core/highlight/client';
import { cn } from '@/utils/cn';
import { type ComponentProps, useMemo } from 'react';
import {
type ComponentProps,
createContext,
type FC,
Suspense,
use,
} from 'react';
export interface DynamicCodeblockProps {
lang: string;
code: string;
/**
* Extra props for the underlying `<CodeBlock />` component.
*
* Ignored if you defined your own `pre` component in `options.components`.
*/
codeblock?: CodeBlockProps;
/**
* Wrap in React `<Suspense />` and provide a fallback.
*
* @defaultValue true
*/
wrapInSuspense?: boolean;
options?: Omit<HighlightOptionsCommon, 'lang'> & HighlightOptionsThemes;
}
const PropsContext = createContext<CodeBlockProps | undefined>(undefined);
function DefaultPre(props: ComponentProps<'pre'>) {
const extraProps = use(PropsContext);
function pre(props: ComponentProps<'pre'>) {
return (
<CodeBlock {...props} className={cn('my-0', props.className)}>
<CodeBlock
{...props}
{...extraProps}
className={cn('my-0', props.className, extraProps?.className)}
>
<Pre>{props.children}</Pre>
</CodeBlock>
);
@@ -18,40 +51,66 @@ function pre(props: ComponentProps<'pre'>) {
export function DynamicCodeBlock({
lang,
code,
codeblock,
options,
wrapInSuspense = true,
}: DynamicCodeblockProps) {
const shikiOptions = {
lang,
...options,
components: {
pre: DefaultPre,
...options?.components,
},
} satisfies HighlightOptions;
let children = <Internal code={code} options={shikiOptions} />;
if (wrapInSuspense)
children = (
<Suspense
fallback={
<Placeholder code={code} components={shikiOptions.components} />
}
>
{children}
</Suspense>
);
return <PropsContext value={codeblock}>{children}</PropsContext>;
}
function Placeholder({
code,
components = {},
}: {
code: string;
components: HighlightOptions['components'];
}) {
const { pre: Pre = 'pre', code: Code = 'code' } = components as Record<
string,
FC
>;
return (
<Pre>
<Code>
{code.split('\n').map((line, i) => (
<span key={i} className="line">
{line}
</span>
))}
</Code>
</Pre>
);
}
function Internal({
code,
options,
}: {
lang: string;
code: string;
options?: Omit<HighlightOptionsCommon, 'lang'> & HighlightOptionsThemes;
options: HighlightOptions;
}) {
const components: HighlightOptionsCommon['components'] = {
pre,
...options?.components,
};
const loading = useMemo(() => {
const Pre = (components.pre ?? 'pre') as 'pre';
const Code = (components.code ?? 'code') as 'code';
return (
<Pre>
<Code>
{code.split('\n').map((line, i) => (
<span key={i} className="line">
{line}
</span>
))}
</Code>
</Pre>
);
// eslint-disable-next-line -- initial value only
}, []);
return useShiki(code, {
lang,
loading,
withPrerenderScript: true,
...options,
components,
});
return useShiki(code, options);
}