2022-09-15 13:52:32 +02:00

292 lines
9.3 KiB
TypeScript

import global from 'global';
import {
AnyFramework,
StoryId,
ViewMode,
StoryContextForLoaders,
StoryContext,
} from '@storybook/csf';
import {
Story,
RenderContext,
StoryStore,
RenderToDOM,
TeardownRenderToDOM,
} from '@storybook/store';
import { Channel } from '@storybook/addons';
import { logger } from '@storybook/client-logger';
import {
STORY_RENDER_PHASE_CHANGED,
STORY_RENDERED,
PLAY_FUNCTION_THREW_EXCEPTION,
} from '@storybook/core-events';
import { Render, RenderType, PREPARE_ABORTED } from './Render';
const { AbortController } = global;
export type RenderPhase =
| 'preparing'
| 'loading'
| 'rendering'
| 'playing'
| 'played'
| 'completed'
| 'aborted'
| 'errored';
function serializeError(error: any) {
try {
const { name = 'Error', message = String(error), stack } = error;
return { name, message, stack };
} catch (e) {
return { name: 'Error', message: String(error) };
}
}
export type RenderContextCallbacks<TFramework extends AnyFramework> = Pick<
RenderContext<TFramework>,
'showMain' | 'showError' | 'showException'
>;
export class StoryRender<TFramework extends AnyFramework> implements Render<TFramework> {
public type: RenderType = 'story';
public story?: Story<TFramework>;
public phase?: RenderPhase;
private abortController?: AbortController;
private canvasElement?: HTMLElement;
private notYetRendered = true;
public disableKeyListeners = false;
private teardownRender: TeardownRenderToDOM = () => {};
public torndown = false;
constructor(
public channel: Channel,
public store: StoryStore<TFramework>,
private renderToScreen: RenderToDOM<TFramework>,
private callbacks: RenderContextCallbacks<TFramework>,
public id: StoryId,
public viewMode: ViewMode,
story?: Story<TFramework>
) {
this.abortController = new AbortController();
// Allow short-circuiting preparing if we happen to already
// have the story (this is used by docs mode)
if (story) {
this.story = story;
// TODO -- what should the phase be now?
// TODO -- should we emit the render phase changed event?
this.phase = 'preparing';
}
}
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 (signal.aborted) {
this.phase = 'aborted';
this.channel.emit(STORY_RENDER_PHASE_CHANGED, { newPhase: this.phase, storyId: this.id });
}
}
async prepare() {
await this.runPhase((this.abortController as AbortController).signal, 'preparing', async () => {
this.story = await this.store.loadStory({ storyId: this.id });
});
if ((this.abortController as AbortController).signal.aborted) {
this.store.cleanupStory(this.story as Story<TFramework>);
throw PREPARE_ABORTED;
}
}
// The two story "renders" are equal and have both loaded the same story
isEqual(other: Render<TFramework>): boolean {
return !!(
this.id === other.id &&
this.story &&
this.story === (other as StoryRender<TFramework>).story
);
}
isPreparing() {
return ['preparing'].includes(this.phase as RenderPhase);
}
isPending() {
return ['rendering', 'playing'].includes(this.phase as RenderPhase);
}
async renderToElement(canvasElement: HTMLElement) {
this.canvasElement = canvasElement;
// FIXME: this comment
// Start the first (initial) render. We don't await here because we need to return the "cleanup"
// function below right away, so if the user changes story during the first render we can cancel
// it without having to first wait for it to finish.
// Whenever the selection changes we want to force the component to be remounted.
return this.render({ initial: true, forceRemount: true });
}
private storyContext() {
if (!this.story) throw new Error(`Cannot call storyContext before preparing`);
return this.store.getStoryContext(this.story);
}
async render({
initial = false,
forceRemount = false,
}: {
initial?: boolean;
forceRemount?: boolean;
} = {}) {
const { canvasElement } = this;
if (!this.story) throw new Error('cannot render when not prepared');
if (!canvasElement) throw new Error('cannot render when canvasElement is unset');
const { id, componentId, title, name, applyLoaders, unboundStoryFn, playFunction } = this.story;
if (forceRemount && !initial) {
// NOTE: we don't check the cancel actually worked here, so the previous
// render could conceivably still be running after this call.
// We might want to change that in the future.
this.cancelRender();
this.abortController = new AbortController();
}
// We need a stable reference to the signal -- if a re-mount happens the
// abort controller may be torn down (above) before we actually check the signal.
const abortSignal = (this.abortController as AbortController).signal;
try {
let loadedContext: Awaited<ReturnType<typeof applyLoaders>>;
await this.runPhase(abortSignal, 'loading', async () => {
loadedContext = await applyLoaders({
...this.storyContext(),
viewMode: this.viewMode,
} as StoryContextForLoaders<TFramework>);
});
if (abortSignal.aborted) {
return;
}
const renderStoryContext: StoryContext<TFramework> = {
...loadedContext!,
// By this stage, it is possible that new args/globals have been received for this story
// and we need to ensure we render it with the new values
...this.storyContext(),
abortSignal,
canvasElement,
};
const renderContext: RenderContext<TFramework> = {
componentId,
title,
kind: title,
id,
name,
story: name,
...this.callbacks,
showError: (error) => {
this.phase = 'errored';
return this.callbacks.showError(error);
},
showException: (error) => {
this.phase = 'errored';
return this.callbacks.showException(error);
},
forceRemount: forceRemount || this.notYetRendered,
storyContext: renderStoryContext,
storyFn: () => unboundStoryFn(renderStoryContext),
unboundStoryFn,
};
await this.runPhase(abortSignal, 'rendering', async () => {
const teardown = await this.renderToScreen(renderContext, canvasElement);
this.teardownRender = teardown || (() => {});
});
this.notYetRendered = false;
if (abortSignal.aborted) return;
// The phase should be 'rendering' but it might be set to 'aborted' by another render cycle
if (forceRemount && playFunction && this.phase !== 'errored') {
this.disableKeyListeners = true;
try {
await this.runPhase(abortSignal, 'playing', async () => {
await playFunction(renderContext.storyContext);
});
await this.runPhase(abortSignal, 'played');
} catch (error) {
logger.error(error);
await this.runPhase(abortSignal, 'errored', async () => {
this.channel.emit(PLAY_FUNCTION_THREW_EXCEPTION, serializeError(error));
});
if (this.story.parameters.throwPlayFunctionExceptions !== false) throw error;
}
this.disableKeyListeners = false;
if (abortSignal.aborted) return;
}
await this.runPhase(abortSignal, 'completed', async () =>
this.channel.emit(STORY_RENDERED, id)
);
} catch (err) {
this.phase = 'errored';
this.callbacks.showException(err as Error);
}
}
async rerender() {
return this.render();
}
async remount() {
return this.render({ forceRemount: true });
}
// If the story is torn down (either a new story is rendered or the docs page removes it)
// we need to consider the fact that the initial render may not be finished
// (possibly the loaders or the play function are still running). We use the controller
// as a method to abort them, ASAP, but this is not foolproof as we cannot control what
// happens inside the user's code.
cancelRender() {
this.abortController?.abort();
}
async teardown() {
this.torndown = true;
this.cancelRender();
// If the story has loaded, we need to cleanup
if (this.story) this.store.cleanupStory(this.story);
// Check if we're done rendering/playing. If not, we may have to reload the page.
// Wait several ticks that may be needed to handle the abort, then try again.
// Note that there's a max of 5 nested timeouts before they're no longer "instant".
for (let i = 0; i < 3; i += 1) {
if (!this.isPending()) {
// eslint-disable-next-line no-await-in-loop
await this.teardownRender();
return;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 0));
}
// If we still haven't completed, reload the page (iframe) to ensure we have a clean slate
// for the next render. Since the reload can take a brief moment to happen, we want to stop
// further rendering by awaiting a never-resolving promise (which is destroyed on reload).
global.window.location.reload();
await new Promise(() => {});
}
}