Skip to content

Commit

Permalink
Merge pull request #21369 from storybookjs/20841-show-docs-errors
Browse files Browse the repository at this point in the history
Docs: Show MDX errors using our error overlay
  • Loading branch information
shilman committed Mar 3, 2023
2 parents 66c5c5e + 11ee42e commit 4e58b01
Show file tree
Hide file tree
Showing 12 changed files with 142 additions and 45 deletions.
53 changes: 39 additions & 14 deletions code/addons/docs/src/DocsRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { Component } from 'react';
import { renderElement, unmountElement } from '@storybook/react-dom-shim';
import type { Renderer, Parameters, DocsContextProps, DocsRenderFunction } from '@storybook/types';
import { Docs, CodeOrSourceMdx, AnchorMdx, HeadersMdx } from '@storybook/blocks';
Expand All @@ -10,33 +10,58 @@ export const defaultComponents: Record<string, any> = {
...HeadersMdx,
};

class ErrorBoundary extends Component<{
showException: (err: Error) => void;
}> {
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

componentDidCatch(err: Error) {
const { showException } = this.props;
showException(err);
}

render() {
const { hasError } = this.state;
const { children } = this.props;

return hasError ? null : children;
}
}

export class DocsRenderer<TRenderer extends Renderer> {
public render: DocsRenderFunction<TRenderer>;

public unmount: (element: HTMLElement) => void;

constructor() {
this.render = (
this.render = async (
context: DocsContextProps<TRenderer>,
docsParameter: Parameters,
element: HTMLElement,
callback: () => void
): void => {
element: HTMLElement
): Promise<void> => {
const components = {
...defaultComponents,
...docsParameter?.components,
};

import('@mdx-js/react')
.then(({ MDXProvider }) =>
renderElement(
<MDXProvider components={components}>
<Docs context={context} docsParameter={docsParameter} />
</MDXProvider>,
element
return new Promise((resolve, reject) => {
import('@mdx-js/react')
.then(({ MDXProvider }) =>
renderElement(
<ErrorBoundary showException={reject}>
<MDXProvider components={components}>
<Docs context={context} docsParameter={docsParameter} />
</MDXProvider>
</ErrorBoundary>,
element
)
)
)
.then(callback);
.then(resolve);
});
};

this.unmount = (element: HTMLElement) => {
Expand Down
3 changes: 3 additions & 0 deletions code/addons/docs/template/stories/docs2/Error.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{/* This file intentionally has an error */}

<Story of={Something} />
1 change: 1 addition & 0 deletions code/lib/preview-api/src/modules/core-client/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ jest.mock('@storybook/global', () => ({
// console.log(global);

jest.mock('@storybook/channel-postmessage', () => ({ createChannel: () => mockChannel }));
jest.mock('@storybook/client-logger');
jest.mock('react-dom');

// for the auto-title test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
// This file lets them rip.

jest.mock('@storybook/channel-postmessage', () => ({ createChannel: () => mockChannel }));
jest.mock('@storybook/client-logger');

jest.mock('./WebView');

Expand Down Expand Up @@ -113,6 +114,30 @@ describe('PreviewWeb', () => {
</div>
`);
});

it('sends docs rendering exceptions to showException', async () => {
const { DocsRenderer } = await import('@storybook/addon-docs');
projectAnnotations.parameters.docs.renderer = () => new DocsRenderer() as any;

document.location.search = '?id=component-one--docs&viewMode=docs';
const preview = new PreviewWeb();

const docsRoot = document.createElement('div');
(
preview.view.prepareForDocs as any as jest.Mock<typeof preview.view.prepareForDocs>
).mockReturnValue(docsRoot as any);
componentOneExports.default.parameters.docs.container.mockImplementationOnce(() => {
throw new Error('Docs rendering error');
});

(
preview.view.showErrorDisplay as any as jest.Mock<typeof preview.view.showErrorDisplay>
).mockClear();
await preview.initialize({ importFn, getProjectAnnotations });
await waitForRender();

expect(preview.view.showErrorDisplay).toHaveBeenCalled();
});
});

describe('onGetGlobalMeta changed (HMR)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const importFn: jest.Mocked<ModuleImportFn> = jest.fn(
);

export const docsRenderer = {
render: jest.fn().mockImplementation((context, parameters, element, cb) => cb()),
render: jest.fn().mockImplementation((context, parameters, element) => Promise.resolve()),
unmount: jest.fn(),
};
export const teardownrenderToCanvas: jest.Mock<TeardownRenderToCanvas> = jest.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -677,8 +677,7 @@ describe('PreviewWeb', () => {
page: componentOneExports.default.parameters.docs.page,
renderer: projectAnnotations.parameters.docs.renderer,
}),
'docs-element',
expect.any(Function)
'docs-element'
);
});

Expand Down Expand Up @@ -736,8 +735,7 @@ describe('PreviewWeb', () => {
page: unattachedDocsExports.default,
renderer: projectAnnotations.parameters.docs.renderer,
}),
'docs-element',
expect.any(Function)
'docs-element'
);
});

Expand Down Expand Up @@ -3200,8 +3198,7 @@ describe('PreviewWeb', () => {
page: newUnattachedDocsExports.default,
renderer: projectAnnotations.parameters.docs.renderer,
}),
'docs-element',
expect.any(Function)
'docs-element'
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,19 @@ export class PreviewWithSelection<TFramework extends Renderer> extends Preview<T
'story'
);
} else if (isMdxEntry(entry)) {
render = new MdxDocsRender<TFramework>(this.channel, this.storyStore, entry);
render = new MdxDocsRender<TFramework>(
this.channel,
this.storyStore,
entry,
this.mainStoryCallbacks(storyId)
);
} else {
render = new CsfDocsRender<TFramework>(this.channel, this.storyStore, entry);
render = new CsfDocsRender<TFramework>(
this.channel,
this.storyStore,
entry,
this.mainStoryCallbacks(storyId)
);
}

// We need to store this right away, so if the story changes during
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Channel } from '@storybook/channels';
import type { Renderer, DocsIndexEntry } from '@storybook/types';
import type { Renderer, DocsIndexEntry, RenderContextCallbacks } from '@storybook/types';
import type { StoryStore } from '../../store';
import { PREPARE_ABORTED } from './Render';

Expand Down Expand Up @@ -36,7 +36,8 @@ it('throws PREPARE_ABORTED if torndown during prepare', async () => {
const render = new CsfDocsRender(
new Channel(),
mockStore as unknown as StoryStore<Renderer>,
entry
entry,
{} as RenderContextCallbacks<Renderer>
);

const preparePromise = render.prepare();
Expand All @@ -61,7 +62,12 @@ it('attached immediately', async () => {
storyFromCSFFile: () => story,
} as unknown as StoryStore<Renderer>;

const render = new CsfDocsRender(new Channel(), store, entry);
const render = new CsfDocsRender(
new Channel(),
store,
entry,
{} as RenderContextCallbacks<Renderer>
);
await render.prepare();

const context = render.docsContext(jest.fn());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { IndexEntry, Renderer, CSFFile, PreparedStory, StoryId } from '@storybook/types';
import type {
IndexEntry,
Renderer,
CSFFile,
PreparedStory,
StoryId,
RenderContextCallbacks,
} from '@storybook/types';
import type { Channel } from '@storybook/channels';
import { DOCS_RENDERED } from '@storybook/core-events';
import type { StoryStore } from '../../../store';
Expand Down Expand Up @@ -43,7 +50,8 @@ export class CsfDocsRender<TRenderer extends Renderer> implements Render<TRender
constructor(
protected channel: Channel,
protected store: StoryStore<TRenderer>,
public entry: IndexEntry
public entry: IndexEntry,
private callbacks: RenderContextCallbacks<TRenderer>
) {
this.id = entry.id;
}
Expand Down Expand Up @@ -118,11 +126,13 @@ export class CsfDocsRender<TRenderer extends Renderer> implements Render<TRender
const renderer = await docsParameter.renderer();
const { render } = renderer as { render: DocsRenderFunction<TRenderer> };
const renderDocs = async () => {
await new Promise<void>((r) =>
try {
// NOTE: it isn't currently possible to use a docs renderer outside of "web" mode.
render(docsContext, docsParameter, canvasElement as any, r)
);
this.channel.emit(DOCS_RENDERED, this.id);
await render(docsContext, docsParameter, canvasElement as any);
this.channel.emit(DOCS_RENDERED, this.id);
} catch (err) {
this.callbacks.showException(err as Error);
}
};

this.rerender = async () => renderDocs();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Channel } from '@storybook/channels';
import type { Renderer, DocsIndexEntry } from '@storybook/types';
import type { Renderer, DocsIndexEntry, RenderContextCallbacks } from '@storybook/types';
import type { StoryStore } from '../../store';
import { PREPARE_ABORTED } from './Render';

Expand Down Expand Up @@ -35,7 +35,8 @@ it('throws PREPARE_ABORTED if torndown during prepare', async () => {
const render = new MdxDocsRender(
new Channel(),
mockStore as unknown as StoryStore<Renderer>,
entry
entry,
{} as RenderContextCallbacks<Renderer>
);

const preparePromise = render.prepare();
Expand All @@ -60,7 +61,12 @@ describe('attaching', () => {
} as unknown as StoryStore<Renderer>;

it('is not attached if you do not call setMeta', async () => {
const render = new MdxDocsRender(new Channel(), store, entry);
const render = new MdxDocsRender(
new Channel(),
store,
entry,
{} as RenderContextCallbacks<Renderer>
);
await render.prepare();

const context = render.docsContext(jest.fn());
Expand All @@ -69,7 +75,12 @@ describe('attaching', () => {
});

it('is attached if you call referenceMeta with attach=true', async () => {
const render = new MdxDocsRender(new Channel(), store, entry);
const render = new MdxDocsRender(
new Channel(),
store,
entry,
{} as RenderContextCallbacks<Renderer>
);
await render.prepare();

const context = render.docsContext(jest.fn());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { IndexEntry, Renderer, CSFFile, ModuleExports, StoryId } from '@storybook/types';
import type {
IndexEntry,
Renderer,
CSFFile,
ModuleExports,
StoryId,
RenderContextCallbacks,
} from '@storybook/types';
import type { Channel } from '@storybook/channels';
import { DOCS_RENDERED } from '@storybook/core-events';
import type { StoryStore } from '../../store';
Expand Down Expand Up @@ -41,7 +48,8 @@ export class MdxDocsRender<TRenderer extends Renderer> implements Render<TRender
constructor(
protected channel: Channel,
protected store: StoryStore<TRenderer>,
public entry: IndexEntry
public entry: IndexEntry,
private callbacks: RenderContextCallbacks<TRenderer>
) {
this.id = entry.id;
}
Expand Down Expand Up @@ -101,11 +109,13 @@ export class MdxDocsRender<TRenderer extends Renderer> implements Render<TRender
const renderer = await docs.renderer();
const { render } = renderer as { render: DocsRenderFunction<TRenderer> };
const renderDocs = async () => {
await new Promise<void>((r) =>
try {
// NOTE: it isn't currently possible to use a docs renderer outside of "web" mode.
render(docsContext, docsParameter, canvasElement as any, r)
);
this.channel.emit(DOCS_RENDERED, this.id);
await render(docsContext, docsParameter, canvasElement as any);
this.channel.emit(DOCS_RENDERED, this.id);
} catch (err) {
this.callbacks.showException(err as Error);
}
};

this.rerender = async () => renderDocs();
Expand Down
5 changes: 2 additions & 3 deletions code/lib/types/src/modules/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,5 @@ export interface DocsContextProps<TRenderer extends Renderer = Renderer> {
export type DocsRenderFunction<TRenderer extends Renderer> = (
docsContext: DocsContextProps<TRenderer>,
docsParameters: Parameters,
element: HTMLElement,
callback: () => void
) => void;
element: HTMLElement
) => Promise<void>;

0 comments on commit 4e58b01

Please sign in to comment.