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: useLiveDemo support iframe demo #2013

Merged
merged 3 commits into from
Jan 19, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/client/pages/Demo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import './index.less';
const DemoRenderPage: FC = () => {
const { id } = useParams();
const { component } = useDemo(id!) || {};
const { node: liveDemoNode, setSource } = useLiveDemo(id!);
const {
node: liveDemoNode,
error: liveDemoError,
setSource,
} = useLiveDemo(id!);
const finalNode = liveDemoNode || (component && createElement(component));

// listen message event for setSource
useEffect(() => {
const handler = (
ev: MessageEvent<{
Expand All @@ -25,6 +30,16 @@ const DemoRenderPage: FC = () => {
return () => window.removeEventListener('message', handler);
}, [setSource]);

// notify parent window that compile done
useEffect(() => {
if (liveDemoNode || liveDemoError) {
window.postMessage({
type: 'dumi.liveDemo.compileDone',
value: { err: liveDemoError },
});
}
}, [liveDemoNode, liveDemoError]);

return finalNode;
};

Expand Down
123 changes: 79 additions & 44 deletions src/client/theme-api/useLiveDemo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@ import {
useState,
type ComponentType,
type ReactNode,
type RefObject,
} from 'react';
import DemoErrorBoundary from './DumiDemo/DemoErrorBoundary';

const THROTTLE_WAIT = 500;

export const useLiveDemo = (id: string) => {
export const useLiveDemo = (
id: string,
opts?: { containerRef?: RefObject<HTMLElement>; iframe?: boolean },
) => {
const { context, asset, renderOpts } = useDemo(id)!;
const [loading, setLoading] = useState(false);
const loadingTimer = useRef<number>();
const taskToken = useRef<number>();
const [demoNode, setDemoNode] = useState<ReactNode>();
const [error, setError] = useState<Error | null>(null);
const setSource = useCallback(
Expand All @@ -30,56 +35,86 @@ export const useLiveDemo = (id: string) => {
THROTTLE_WAIT - 1,
);

const entryFileName = Object.keys(asset.dependencies).find(
(k) => asset.dependencies[k].type === 'FILE',
)!;
const require = (v: string) => {
if (v in context!) return context![v];
throw new Error(`Cannot find module: ${v}`);
};
const exports: { default?: ComponentType } = {};
const module = { exports };
let entryFileCode = source[entryFileName];
if (opts?.iframe && opts?.containerRef?.current) {
const iframeWindow =
opts.containerRef.current.querySelector('iframe')!.contentWindow!;

try {
// load renderToStaticMarkup in async way
const renderToStaticMarkupDeferred = import('react-dom/server').then(
({ renderToStaticMarkup }) => renderToStaticMarkup,
);
await new Promise<void>((resolve) => {
const handler = (
ev: MessageEvent<{
type: string;
value: { err: null | Error };
}>,
) => {
if (ev.data.type.startsWith('dumi.liveDemo.compileDone')) {
iframeWindow.removeEventListener('message', handler);
setError(ev.data.value.err);
resolve();
}
};

// compile entry file code
entryFileCode = await renderOpts!.compile!(entryFileCode, {
filename: entryFileName,
iframeWindow.addEventListener('message', handler);
iframeWindow.postMessage({
type: 'dumi.liveDemo.setSource',
value: source,
});
});
} else {
const entryFileName = Object.keys(asset.dependencies).find(
(k) => asset.dependencies[k].type === 'FILE',
)!;
const require = (v: string) => {
if (v in context!) return context![v];
throw new Error(`Cannot find module: ${v}`);
};
const exports: { default?: ComponentType } = {};
const module = { exports };
const token = (taskToken.current = Math.random());
let entryFileCode = source[entryFileName];

// initial component with fake runtime
new Function('module', 'exports', 'require', entryFileCode)(
module,
exports,
require,
);
try {
// load renderToStaticMarkup in async way
const renderToStaticMarkupDeferred = import(
'react-dom/server'
).then(({ renderToStaticMarkup }) => renderToStaticMarkup);

const newDemoNode = createElement(
DemoErrorBoundary,
null,
createElement(exports.default!),
);
const oError = console.error;
// compile entry file code
entryFileCode = await renderOpts!.compile!(entryFileCode, {
filename: entryFileName,
});

// hijack console.error to avoid useLayoutEffect error
console.error = (...args) =>
!args[0].includes('useLayoutEffect does nothing on the server') &&
oError.apply(console, args);
// skip current task if another task is running
if (token !== taskToken.current) return;

// check component is able to render, to avoid show react overlay error
(await renderToStaticMarkupDeferred)(newDemoNode);
console.error = oError;
// initial component with fake runtime
new Function('module', 'exports', 'require', entryFileCode)(
module,
exports,
require,
);

// set new demo node with passing source
setDemoNode(newDemoNode);
setError(null);
} catch (err: any) {
setError(err);
const newDemoNode = createElement(
DemoErrorBoundary,
null,
createElement(exports.default!),
);
const oError = console.error;

// hijack console.error to avoid useLayoutEffect error
console.error = (...args) =>
!args[0].includes('useLayoutEffect does nothing on the server') &&
oError.apply(console, args);

// check component is able to render, to avoid show react overlay error
(await renderToStaticMarkupDeferred)(newDemoNode);
console.error = oError;

// set new demo node with passing source
setDemoNode(newDemoNode);
setError(null);
} catch (err: any) {
setError(err);
}
}

// reset loading status
Expand All @@ -89,7 +124,7 @@ export const useLiveDemo = (id: string) => {
THROTTLE_WAIT,
{ leading: true },
) as (source: Record<string, string>) => Promise<void>,
[context],
[context, asset, renderOpts],
);

return { node: demoNode, loading, error, setSource };
Expand Down
18 changes: 5 additions & 13 deletions src/client/theme-default/builtins/Previewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ const Previewer: FC<IPreviewerProps> = (props) => {
error: liveDemoError,
loading: liveDemoLoading,
setSource: setLiveDemoSource,
} = useLiveDemo(props.asset.id);
} = useLiveDemo(props.asset.id, {
iframe: Boolean(props.iframe),
containerRef: demoContainer,
});

return (
<div
Expand Down Expand Up @@ -72,18 +75,7 @@ const Previewer: FC<IPreviewerProps> = (props) => {
)}
<PreviewerActions
{...props}
onSourceChange={(source) => {
setLiveDemoSource(source);

if (props.iframe) {
demoContainer
.current!.querySelector('iframe')!
.contentWindow!.postMessage({
type: 'dumi.liveDemo.setSource',
value: source,
});
}
}}
onSourceChange={setLiveDemoSource}
demoContainer={
props.iframe
? (demoContainer.current?.firstElementChild as HTMLIFrameElement)
Expand Down
11 changes: 9 additions & 2 deletions src/loaders/markdown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,20 @@ export const demos = {
this: NonNullable<typeof demos>[0],
) {
// do not render context for inline demo
if (!('resolveMap' in this)) return 'undefined';
if (!('resolveMap' in this) || !('asset' in this)) return 'undefined';

const entryFileName = Object.keys(this.asset.dependencies)[0];

// render context for normal demo
const context = Object.entries(this.resolveMap).reduce(
(acc, [key, path]) => ({
...acc,
[key]: `{{{require('${path}')}}}`,
// omit entry file
...(key !== entryFileName
? {
[key]: `{{{require('${path}')}}}`,
}
: {}),
}),
{},
);
Expand Down
Loading