Skip to content

Commit

Permalink
feat: useLiveDemo support iframe demo (#2013)
Browse files Browse the repository at this point in the history
* feat: useLiveDemo support iframe demo

* refactor: exclude demo entry file from context

* perf: skip outdated tasks for useLiveDemo
  • Loading branch information
PeachScript committed Jan 19, 2024
1 parent 14620d9 commit 9fb0a0e
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 60 deletions.
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

0 comments on commit 9fb0a0e

Please sign in to comment.