diff --git a/examples/official-storybook/config.js b/examples/official-storybook/config.js
index 7ba496088485..ef9842a7e621 100644
--- a/examples/official-storybook/config.js
+++ b/examples/official-storybook/config.js
@@ -18,7 +18,14 @@ setOptions({
theme: themes.dark,
});
-addDecorator(story => {story()});
+addDecorator(
+ (story, { kind }) =>
+ kind === 'Core|Errors' ? (
+ story()
+ ) : (
+ {story()}
+ )
+);
configureViewport({
viewports: {
diff --git a/examples/official-storybook/stories/core.stories.js b/examples/official-storybook/stories/core.stories.js
index 856042ee22be..35088648b742 100644
--- a/examples/official-storybook/stories/core.stories.js
+++ b/examples/official-storybook/stories/core.stories.js
@@ -3,6 +3,7 @@ import { storiesOf, addParameters } from '@storybook/react';
import addons from '@storybook/addons';
import Events from '@storybook/core-events';
import { Button } from '@storybook/components';
+import { navigator } from 'global';
const globalParameter = 'globalParameter';
const chapterParameter = 'chapterParameter';
@@ -31,3 +32,19 @@ const increment = () => {
storiesOf('Core|Events', module).add('Force re-render', () => (
));
+
+// Skip these stories in storyshots, they will throw -- NOTE: would rather do this
+// via a params API, see https://github.com/storybooks/storybook/pull/3967#issuecomment-411616023
+if (
+ navigator &&
+ navigator.userAgent &&
+ !(navigator.userAgent.indexOf('jsdom') > -1) &&
+ !(navigator.userAgent.indexOf('Chromatic') > -1)
+) {
+ storiesOf('Core|Errors', module)
+ .add('story throws exception', () => {
+ throw new Error('error');
+ })
+ // Story does not return something react can render
+ .add('story errors', () => null);
+}
diff --git a/lib/core-events/index.js b/lib/core-events/index.js
index c1cbe2ea8819..08e6a0535035 100644
--- a/lib/core-events/index.js
+++ b/lib/core-events/index.js
@@ -10,4 +10,6 @@ module.exports = {
FORCE_RE_RENDER: 'forceReRender',
REGISTER_SUBSCRIPTION: 'registerSubscription',
STORY_RENDERED: 'storyRendered',
+ STORY_ERRORED: 'storyErrored',
+ STORY_THREW_EXCEPTION: 'storyThrewException',
};
diff --git a/lib/core/src/client/preview/start.js b/lib/core/src/client/preview/start.js
index 613692b90e5d..29cbccad0cc3 100644
--- a/lib/core/src/client/preview/start.js
+++ b/lib/core/src/client/preview/start.js
@@ -43,14 +43,19 @@ function showErrorDisplay({ message, stack }) {
document.body.classList.add(classes.ERROR);
}
+// showError is used by the various app layers to inform the user they have done something
+// wrong -- for instance returned the wrong thing from a story
function showError({ title, description }) {
+ addons.getChannel().emit(Events.STORY_ERRORED, { title, description });
showErrorDisplay({
message: title,
stack: description,
});
}
+// showException is used if we fail to render the story and it is uncaught by the app layer
function showException(exception) {
+ addons.getChannel().emit(Events.STORY_THREW_EXCEPTION, exception);
showErrorDisplay(exception);
// Log the stack to the console. So, user could check the source code.
diff --git a/lib/core/src/client/preview/start.test.js b/lib/core/src/client/preview/start.test.js
new file mode 100644
index 000000000000..df11855a26b4
--- /dev/null
+++ b/lib/core/src/client/preview/start.test.js
@@ -0,0 +1,101 @@
+import addons from '@storybook/addons';
+import Events from '@storybook/core-events';
+import { document } from 'global';
+
+import start from './start';
+
+jest.mock('@storybook/client-logger');
+jest.mock('@storybook/addons');
+jest.mock('global', () => ({
+ navigator: { userAgent: 'browser' },
+ window: {
+ addEventListener: jest.fn(),
+ location: { search: '' },
+ history: { replaceState: jest.fn() },
+ },
+ document: {
+ addEventListener: jest.fn(),
+ getElementById: jest.fn().mockReturnValue({}),
+ body: { classList: { add: jest.fn(), remove: jest.fn() } },
+ documentElement: {},
+ },
+}));
+
+function mockEmit() {
+ const emit = jest.fn();
+ addons.getChannel.mockReturnValue({ emit });
+
+ return emit;
+}
+
+it('renders nopreview when you have no stories', () => {
+ const emit = mockEmit();
+
+ const render = jest.fn();
+
+ start(render);
+
+ expect(render).not.toHaveBeenCalled();
+ expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-nopreview');
+ expect(emit).toHaveBeenCalledWith(Events.STORY_RENDERED);
+});
+
+it('calls render when you add a story', () => {
+ const emit = mockEmit();
+
+ const render = jest.fn();
+
+ const { clientApi, configApi } = start(render);
+
+ emit.mockReset();
+ configApi.configure(() => {
+ clientApi.storiesOf('kind', {}).add('story', () => {});
+ }, {});
+
+ expect(render).toHaveBeenCalled();
+ expect(emit).toHaveBeenCalledWith(Events.STORY_RENDERED);
+});
+
+it('emits an exception and shows error when your story throws', () => {
+ const emit = mockEmit();
+
+ const render = jest.fn().mockImplementation(() => {
+ throw new Error('Some exception');
+ });
+
+ const { clientApi, configApi } = start(render);
+
+ emit.mockReset();
+ document.body.classList.add.mockReset();
+ configApi.configure(() => {
+ clientApi.storiesOf('kind', {}).add('story', () => {});
+ }, {});
+
+ expect(render).toHaveBeenCalled();
+ expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-errordisplay');
+ expect(emit).toHaveBeenCalledWith(Events.STORY_THREW_EXCEPTION, expect.any(Error));
+});
+
+it('emits an error and shows error when your framework calls showError', () => {
+ const emit = mockEmit();
+
+ const error = {
+ title: 'Some error',
+ description: 'description',
+ };
+ const render = jest.fn().mockImplementation(({ showError }) => {
+ showError(error);
+ });
+
+ const { clientApi, configApi } = start(render);
+
+ emit.mockReset();
+ document.body.classList.add.mockReset();
+ configApi.configure(() => {
+ clientApi.storiesOf('kind', {}).add('story', () => {});
+ }, {});
+
+ expect(render).toHaveBeenCalled();
+ expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-errordisplay');
+ expect(emit).toHaveBeenCalledWith(Events.STORY_ERRORED, error);
+});