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

Core: Refactor phases to run in order loading -> rendering -> playing #28431

Merged
merged 6 commits into from
Jul 3, 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
Original file line number Diff line number Diff line change
Expand Up @@ -3556,6 +3556,7 @@ describe('PreviewWeb', () => {
await (preview.storyStore as StoryStore<Renderer>)?.loadStory({
storyId: 'component-one--b',
}),
{} as any,
{} as any
);
await waitForRenderPhase('playing');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,11 +308,7 @@ export class PreviewWithSelection<TRenderer extends Renderer> extends Preview<TR
render = new StoryRender<TRenderer>(
this.channel,
this.storyStoreValue,
(...args: Parameters<typeof renderToCanvas>) => {
// At the start of renderToCanvas we make the story visible (see note in WebView)
this.view.showStoryDuringRender();
return renderToCanvas(...args);
},
renderToCanvas,
this.mainStoryCallbacks(storyId),
storyId,
'story'
Expand Down Expand Up @@ -434,6 +430,7 @@ export class PreviewWithSelection<TRenderer extends Renderer> extends Preview<TR
// UTILITIES
mainStoryCallbacks(storyId: StoryId) {
return {
showStoryDuringRender: () => this.view.showStoryDuringRender(),
showMain: () => this.view.showMain(),
showError: (err: { title: string; description: string }) => this.renderError(storyId, err),
showException: (err: Error) => this.renderException(storyId, err),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Channel } from '@storybook/core/channels';
import type { PreparedStory, Renderer, StoryContext, StoryIndexEntry } from '@storybook/core/types';
import type { StoryStore } from '../../store';
Expand All @@ -26,6 +26,11 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));

window.location = { reload: vi.fn() } as any;

const mountSpy = vi.fn(async (context) => {
await context.renderToCanvas();
return context.canvas;
});

const buildStory = (overrides: Partial<PreparedStory> = {}): PreparedStory =>
({
id: 'id',
Expand All @@ -36,12 +41,7 @@ const buildStory = (overrides: Partial<PreparedStory> = {}): PreparedStory =>
applyBeforeEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(),
mount: vi.fn((context: StoryContext) => {
return async () => {
await context.renderToCanvas();
return context.canvas;
};
}),
mount: (context: StoryContext) => () => mountSpy(context),
...overrides,
}) as any;

Expand All @@ -53,6 +53,10 @@ const buildStore = (overrides: Partial<StoryStore<Renderer>> = {}): StoryStore<R
...overrides,
}) as any;

beforeEach(() => {
vi.restoreAllMocks();
});

describe('StoryRender', () => {
it('does run play function if passed autoplay=true', async () => {
const story = buildStory();
Expand Down Expand Up @@ -133,12 +137,7 @@ describe('StoryRender', () => {
});

it('calls mount if play function does not destructure mount', async () => {
const actualMount = vi.fn(async (context) => {
await context.renderToCanvas();
return {};
});
const story = buildStory({
mount: (context) => () => actualMount(context) as any,
playFunction: () => {},
});
const render = new StoryRender(
Expand All @@ -153,17 +152,14 @@ describe('StoryRender', () => {
);

await render.renderToElement({} as any);
expect(actualMount).toHaveBeenCalled();
expect(mountSpy).toHaveBeenCalledOnce();
});

it('does not call mount if play function destructures mount', async () => {
const actualMount = vi.fn(async (context) => {
await context.renderToCanvas();
return context.canvas;
});
it('does not call mount twice if mount called in play function', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tmeasday I changed the assertion. As we prevent calling mount twice, if the user wants to mount in play.

It will always be called once. If not, we throw an error.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kasperpeulen Can we add a test for calling mount twice? Or did I miss it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We kind of auto-mount before the play function, except when you destructure mount.
So this test ensures that mount is only called once in that scenario and not twice.

Copy link
Member

@shilman shilman Jul 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. What if I have a play function like this though?

play: async ({ mount }) => {
  // some setup
  await mount();
  // who knows what 
  await mount();
  // etc.
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, let's add a test that we throw in that scenario! But I agree this change is an improvement.

const story = buildStory({
mount: (context) => () => actualMount(context) as any,
playFunction: ({ mount }) => {},
playFunction: async ({ mount }) => {
await mount();
},
});
const render = new StoryRender(
new Channel({}),
Expand All @@ -177,16 +173,11 @@ describe('StoryRender', () => {
);

await render.renderToElement({} as any);
expect(actualMount).not.toHaveBeenCalled();
expect(mountSpy).toHaveBeenCalledOnce();
});

it('errors if play function calls mount without destructuring', async () => {
const actualMount = vi.fn(async (context) => {
await context.renderToCanvas();
return {};
});
const story = buildStory({
mount: (context) => () => actualMount(context) as any,
playFunction: async (context) => {
await context.mount();
},
Expand All @@ -207,16 +198,40 @@ describe('StoryRender', () => {
expect(view.showException).toHaveBeenCalled();
});

it('errors if play function destructures mount but does not call it', async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

const story = buildStory({
playFunction: async ({ mount }) => {
// forget to call mount
},
});
const view = { showException: vi.fn() };
const render = new StoryRender(
new Channel({}),
buildStore(),
vi.fn() as any,
view as any,
entry.id,
'story',
{ autoplay: true },
story
);

await render.renderToElement({} as any);
expect(view.showException).toHaveBeenCalled();
});

it('enters rendering phase during play if play function calls mount', async () => {
const actualMount = vi.fn(async (context) => {
await context.renderToCanvas();
return {};
expect(render.phase).toBe('rendering');
return context.canvas;
});
const story = buildStory({
mount: (context) => () => actualMount(context) as any,
playFunction: ({ mount }) => {
playFunction: async ({ mount }) => {
expect(render.phase).toBe('loading');
await mount();
expect(render.phase).toBe('playing');
mount();
},
});
const render = new StoryRender(
Expand Down
64 changes: 43 additions & 21 deletions code/core/src/preview-api/modules/preview-web/render/StoryRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { StoryStore } from '../../store';
import type { Render, RenderType } from './Render';
import { PREPARE_ABORTED } from './Render';
import { mountDestructured } from './mount-utils';
import { MountMustBeDestructuredError } from '@storybook/core-events/preview-errors';
import { MountMustBeDestructuredError, NoStoryMountedError } from '@storybook/core/preview-errors';

import type {
Canvas,
Expand Down Expand Up @@ -71,7 +71,7 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer
public channel: Channel,
public store: StoryStore<TRenderer>,
private renderToScreen: RenderToCanvas<TRenderer>,
private callbacks: RenderContextCallbacks<TRenderer>,
private callbacks: RenderContextCallbacks<TRenderer> & { showStoryDuringRender?: () => void },
public id: StoryId,
public viewMode: StoryContext['viewMode'],
public renderOptions: StoryRenderOptions = { autoplay: true, forceInitialArgs: false },
Expand All @@ -92,12 +92,19 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer
private async runPhase(signal: AbortSignal, phase: RenderPhase, phaseFn?: () => Promise<void>) {
this.phase = phase;
this.channel.emit(STORY_RENDER_PHASE_CHANGED, { newPhase: this.phase, storyId: this.id });
if (phaseFn) await phaseFn();
if (phaseFn) {
await phaseFn();
this.checkIfAborted(signal);
}
}

private checkIfAborted(signal: AbortSignal): boolean {
if (signal.aborted) {
this.phase = 'aborted';
this.channel.emit(STORY_RENDER_PHASE_CHANGED, { newPhase: this.phase, storyId: this.id });
return true;
}
return false;
}

async prepare() {
Expand Down Expand Up @@ -196,22 +203,29 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer
step: (label, play) => runStep(label, play, context),
context: null!,
canvas: {} as Canvas,
mount: null!,
renderToCanvas: async () => {
const teardown = await this.renderToScreen(renderContext, canvasElement);
this.teardownRender = teardown || (() => {});
mounted = true;
},
// The story provides (set in a renderer) a mount function that is a higher order function
// (context) => (...args) => Canvas
//
// Before assigning it to the context, we resolve the context dependency,
// so that a user can just call it as await mount(...args) in their play function.
mount: async (...args) => {
this.callbacks.showStoryDuringRender?.();
let mountReturn: Awaited<ReturnType<StoryContext['mount']>> = null!;
await this.runPhase(abortSignal, 'rendering', async () => {
const teardown = await this.renderToScreen(renderContext, canvasElement);
this.teardownRender = teardown || (() => {});
mounted = true;
mountReturn = await story.mount(context)(...args);
});

if (isMountDestructured) {
// put the phase back to playing if mount is used inside a play function
await this.runPhase(abortSignal, 'playing', async () => {});
}
// start playing phase if mount is used inside a play function
if (isMountDestructured) await this.runPhase(abortSignal, 'playing');
return mountReturn;
},
};

context.context = context;
context.mount = this.story.mount(context);

const renderContext: RenderContext<TRenderer> = {
componentId,
Expand Down Expand Up @@ -241,12 +255,10 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer

if (abortSignal.aborted) return;

await this.runPhase(abortSignal, 'beforeEach', async () => {
const cleanupCallbacks = await applyBeforeEach(context);
this.store.addCleanupCallbacks(story, cleanupCallbacks);
});
const cleanupCallbacks = await applyBeforeEach(context);
this.store.addCleanupCallbacks(story, cleanupCallbacks);

if (abortSignal.aborted) return;
if (this.checkIfAborted(abortSignal)) return;

if (!mounted && !isMountDestructured) {
await context.mount();
Expand All @@ -272,16 +284,26 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer
context.mount = async () => {
throw new MountMustBeDestructuredError({ playFunction: playFunction.toString() });
};
}
await this.runPhase(abortSignal, 'playing', async () => {
await this.runPhase(abortSignal, 'playing', async () => playFunction(context));
} else {
// when mount is used the playing phase will start later, right after mount is called in the play function
await playFunction(context);
});
}

if (!mounted) {
throw new NoStoryMountedError();
}
Comment on lines +293 to +295
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yannbf this should solve your problem with a endless loaders, if you don't mount

this.checkIfAborted(abortSignal);

if (!ignoreUnhandledErrors && unhandledErrors.size > 0) {
await this.runPhase(abortSignal, 'errored');
} else {
await this.runPhase(abortSignal, 'played');
}
} catch (error) {
// Remove the loading screen, even if there was an error before rendering
this.callbacks.showStoryDuringRender?.();

await this.runPhase(abortSignal, 'errored', async () => {
this.channel.emit(PLAY_FUNCTION_THREW_EXCEPTION, serializeError(error));
});
Expand Down
23 changes: 23 additions & 0 deletions code/core/src/preview-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,29 @@ export class TestingLibraryMustBeConfiguredError extends StorybookError {
}
}

export class NoStoryMountedError extends StorybookError {
readonly category = Category.PREVIEW_API;

readonly code = 14;

template() {
return dedent`
No story is mounted in your story.

This usually occurs when you destructure mount in the play function, but forgot to call it.

For example:

async play({ mount, canvasElement }) {
// 👈mount should be called: await mount();
const canvas = within(canvasElement);
const button = await canvas.findByRole('button');
await userEvent.click(button);
};
`;
}
}

export class NextJsSharpError extends StorybookError {
readonly category = Category.FRAMEWORK_NEXTJS;

Expand Down