fbredius/storybook

View on GitHub
lib/preview-web/src/PreviewWeb.tsx

Summary

Maintainability
F
3 days
Test Coverage
import deprecate from 'util-deprecate';
import dedent from 'ts-dedent';
import global from 'global';
import { SynchronousPromise } from 'synchronous-promise';
import Events, { IGNORED_EXCEPTION } from '@storybook/core-events';
import { logger } from '@storybook/client-logger';
import { addons, Channel } from '@storybook/addons';
import {
  AnyFramework,
  StoryId,
  ProjectAnnotations,
  Args,
  Globals,
  ViewMode,
  StoryContextForLoaders,
  StoryContext,
} from '@storybook/csf';
import {
  ModuleImportFn,
  Selection,
  Story,
  RenderContext,
  CSFFile,
  StoryStore,
  StorySpecifier,
  StoryIndex,
} from '@storybook/store';

import { WebProjectAnnotations } from './types';

import { UrlStore } from './UrlStore';
import { WebView } from './WebView';

const { window: globalWindow, AbortController, fetch } = global;

function focusInInput(event: Event) {
  const target = event.target as Element;
  return /input|textarea/i.test(target.tagName) || target.getAttribute('contenteditable') !== null;
}

function createController(): AbortController {
  if (AbortController) return new AbortController();
  // Polyfill for IE11
  return {
    signal: { aborted: false },
    abort() {
      this.signal.aborted = true;
    },
  } as AbortController;
}

export type RenderPhase =
  | 'loading'
  | 'rendering'
  | 'playing'
  | 'played'
  | 'completed'
  | 'aborted'
  | 'errored';
type PromiseLike<T> = Promise<T> | SynchronousPromise<T>;
type MaybePromise<T> = Promise<T> | T;
type StoryCleanupFn = () => MaybePromise<void>;

const STORY_INDEX_PATH = './stories.json';

export class PreviewWeb<TFramework extends AnyFramework> {
  channel: Channel;

  serverChannel?: Channel;

  urlStore: UrlStore;

  storyStore: StoryStore<TFramework>;

  view: WebView;

  getStoryIndex?: () => StoryIndex;

  importFn?: ModuleImportFn;

  renderToDOM: WebProjectAnnotations<TFramework>['renderToDOM'];

  previewEntryError?: Error;

  previousSelection: Selection;

  previousStory: Story<TFramework>;

  previousCleanup: StoryCleanupFn;

  abortController: AbortController;

  disableKeyListeners: boolean;

  constructor() {
    this.channel = addons.getChannel();
    if (global.FEATURES?.storyStoreV7 && addons.hasServerChannel()) {
      this.serverChannel = addons.getServerChannel();
    }
    this.view = new WebView();

    this.urlStore = new UrlStore();
    this.storyStore = new StoryStore();
    // Add deprecated APIs for back-compat
    // @ts-ignore
    this.storyStore.getSelection = deprecate(
      () => this.urlStore.selection,
      dedent`
        \`__STORYBOOK_STORY_STORE__.getSelection()\` is deprecated and will be removed in 7.0.
  
        To get the current selection, use the \`useStoryContext()\` hook from \`@storybook/addons\`.
      `
    );
  }

  // INITIALIZATION

  // NOTE: the reason that the preview and store's initialization code is written in a promise
  // style and not `async-await`, and the use of `SynchronousPromise`s is in order to allow
  // storyshots to immediately call `raw()` on the store without waiting for a later tick.
  // (Even simple things like `Promise.resolve()` and `await` involve the callback happening
  // in the next promise "tick").
  // See the comment in `storyshots-core/src/api/index.ts` for more detail.
  initialize({
    getStoryIndex,
    importFn,
    getProjectAnnotations,
  }: {
    // In the case of the v6 store, we can only get the index from the facade *after*
    // getProjectAnnotations has been run, thus this slightly awkward approach
    getStoryIndex?: () => StoryIndex;
    importFn: ModuleImportFn;
    getProjectAnnotations: () => MaybePromise<WebProjectAnnotations<TFramework>>;
  }) {
    // We save these two on initialization in case `getProjectAnnotations` errors,
    // in which case we may need them later when we recover.
    this.getStoryIndex = getStoryIndex;
    this.importFn = importFn;

    this.setupListeners();

    return this.getProjectAnnotationsOrRenderError(getProjectAnnotations).then(
      (projectAnnotations) => this.initializeWithProjectAnnotations(projectAnnotations)
    );
  }

  setupListeners() {
    globalWindow.onkeydown = this.onKeydown.bind(this);

    this.serverChannel?.on(Events.STORY_INDEX_INVALIDATED, this.onStoryIndexChanged.bind(this));

    this.channel.on(Events.SET_CURRENT_STORY, this.onSetCurrentStory.bind(this));
    this.channel.on(Events.UPDATE_QUERY_PARAMS, this.onUpdateQueryParams.bind(this));
    this.channel.on(Events.UPDATE_GLOBALS, this.onUpdateGlobals.bind(this));
    this.channel.on(Events.UPDATE_STORY_ARGS, this.onUpdateArgs.bind(this));
    this.channel.on(Events.RESET_STORY_ARGS, this.onResetArgs.bind(this));
  }

  getProjectAnnotationsOrRenderError(
    getProjectAnnotations: () => MaybePromise<WebProjectAnnotations<TFramework>>
  ): PromiseLike<ProjectAnnotations<TFramework>> {
    return SynchronousPromise.resolve()
      .then(getProjectAnnotations)
      .then((projectAnnotations) => {
        this.renderToDOM = projectAnnotations.renderToDOM;
        if (!this.renderToDOM) {
          throw new Error(dedent`
            Expected your framework's preset to export a \`renderToDOM\` field.

            Perhaps it needs to be upgraded for Storybook 6.4?

            More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field          
          `);
        }
        return projectAnnotations;
      })
      .catch((err) => {
        // This is an error extracting the projectAnnotations (i.e. evaluating the previewEntries) and
        // needs to be show to the user as a simple error
        this.renderPreviewEntryError('Error reading preview.js:', err);
        throw err;
      });
  }

  // If initialization gets as far as project annotations, this function runs.
  initializeWithProjectAnnotations(projectAnnotations: WebProjectAnnotations<TFramework>) {
    this.storyStore.setProjectAnnotations(projectAnnotations);

    this.setInitialGlobals();

    let storyIndexPromise: PromiseLike<StoryIndex>;
    if (global.FEATURES?.storyStoreV7) {
      storyIndexPromise = this.getStoryIndexFromServer();
    } else {
      if (!this.getStoryIndex) {
        throw new Error('No `getStoryIndex` passed defined in v6 mode');
      }
      storyIndexPromise = SynchronousPromise.resolve().then(this.getStoryIndex);
    }

    return storyIndexPromise
      .then((storyIndex: StoryIndex) => this.initializeWithStoryIndex(storyIndex))
      .catch((err) => {
        this.renderPreviewEntryError('Error loading story index:', err);
        throw err;
      });
  }

  async setInitialGlobals() {
    const { globals } = this.urlStore.selectionSpecifier || {};
    if (globals) {
      this.storyStore.globals.updateFromPersisted(globals);
    }
    this.emitGlobals();
  }

  emitGlobals() {
    this.channel.emit(Events.SET_GLOBALS, {
      globals: this.storyStore.globals.get() || {},
      globalTypes: this.storyStore.projectAnnotations.globalTypes || {},
    });
  }

  async getStoryIndexFromServer() {
    const result = await fetch(STORY_INDEX_PATH);
    if (result.status === 200) return result.json() as StoryIndex;

    throw new Error(await result.text());
  }

  // If initialization gets as far as the story index, this function runs.
  initializeWithStoryIndex(storyIndex: StoryIndex) {
    return this.storyStore
      .initialize({
        storyIndex,
        importFn: this.importFn,
        cache: !global.FEATURES?.storyStoreV7,
      })
      .then(() => {
        if (!global.FEATURES?.storyStoreV7) {
          this.channel.emit(Events.SET_STORIES, this.storyStore.getSetStoriesPayload());
        }

        return this.selectSpecifiedStory();
      });
  }

  // Use the selection specifier to choose a story, then render it
  async selectSpecifiedStory() {
    if (!this.urlStore.selectionSpecifier) {
      this.renderMissingStory();
      return;
    }

    const { storySpecifier, viewMode, args } = this.urlStore.selectionSpecifier;
    const storyId = this.storyStore.storyIndex.storyIdFromSpecifier(storySpecifier);

    if (!storyId) {
      if (storySpecifier === '*') {
        this.renderStoryLoadingException(
          storySpecifier,
          new Error(dedent`
            Couldn't find any stories in your Storybook.
            - Please check your stories field of your main.js config.
            - Also check the browser console and terminal for error messages.
          `)
        );
      } else {
        this.renderStoryLoadingException(
          storySpecifier,
          new Error(dedent`
            Couldn't find story matching '${storySpecifier}'.
            - Are you sure a story with that id exists?
            - Please check your stories field of your main.js config.
            - Also check the browser console and terminal for error messages.
          `)
        );
      }

      return;
    }

    this.urlStore.setSelection({ storyId, viewMode });
    this.channel.emit(Events.STORY_SPECIFIED, this.urlStore.selection);

    this.channel.emit(Events.CURRENT_STORY_WAS_SET, this.urlStore.selection);

    await this.renderSelection({ persistedArgs: args });
  }

  // EVENT HANDLERS

  // This happens when a config file gets reloaded
  async onGetProjectAnnotationsChanged({
    getProjectAnnotations,
  }: {
    getProjectAnnotations: () => MaybePromise<ProjectAnnotations<TFramework>>;
  }) {
    delete this.previewEntryError;

    const projectAnnotations = await this.getProjectAnnotationsOrRenderError(getProjectAnnotations);
    if (!this.storyStore.projectAnnotations) {
      await this.initializeWithProjectAnnotations(projectAnnotations);
      return;
    }

    await this.storyStore.setProjectAnnotations(projectAnnotations);
    this.emitGlobals();
    this.renderSelection();
  }

  async onStoryIndexChanged() {
    delete this.previewEntryError;

    if (!this.storyStore.projectAnnotations) {
      // We haven't successfully set project annotations yet,
      // we need to do that before we can do anything else.
      return;
    }

    try {
      const storyIndex = await this.getStoryIndexFromServer();

      // This is the first time the story index worked, let's load it into the store
      if (!this.storyStore.storyIndex) {
        await this.initializeWithStoryIndex(storyIndex);
      }

      // Update the store with the new stories.
      await this.onStoriesChanged({ storyIndex });
    } catch (err) {
      this.renderPreviewEntryError('Error loading story index:', err);
      throw err;
    }
  }

  // This happens when a glob gets HMR-ed
  async onStoriesChanged({
    importFn,
    storyIndex,
  }: {
    importFn?: ModuleImportFn;
    storyIndex?: StoryIndex;
  }) {
    await this.storyStore.onStoriesChanged({ importFn, storyIndex });
    if (!global.FEATURES?.storyStoreV7) {
      this.channel.emit(Events.SET_STORIES, await this.storyStore.getSetStoriesPayload());
    }

    if (this.urlStore.selection) {
      await this.renderSelection();
    } else {
      // Our selection has never applied before, but maybe it does now, let's try!
      await this.selectSpecifiedStory();
    }
  }

  onKeydown(event: KeyboardEvent) {
    if (!this.disableKeyListeners && !focusInInput(event)) {
      // We have to pick off the keys of the event that we need on the other side
      const { altKey, ctrlKey, metaKey, shiftKey, key, code, keyCode } = event;
      this.channel.emit(Events.PREVIEW_KEYDOWN, {
        event: { altKey, ctrlKey, metaKey, shiftKey, key, code, keyCode },
      });
    }
  }

  onSetCurrentStory(selection: Selection) {
    this.urlStore.setSelection(selection);
    this.channel.emit(Events.CURRENT_STORY_WAS_SET, this.urlStore.selection);
    this.renderSelection();
  }

  onUpdateQueryParams(queryParams: any) {
    this.urlStore.setQueryParams(queryParams);
  }

  onUpdateGlobals({ globals }: { globals: Globals }) {
    this.storyStore.globals.update(globals);

    this.channel.emit(Events.GLOBALS_UPDATED, {
      globals: this.storyStore.globals.get(),
      initialGlobals: this.storyStore.globals.initialGlobals,
    });
  }

  onUpdateArgs({ storyId, updatedArgs }: { storyId: StoryId; updatedArgs: Args }) {
    this.storyStore.args.update(storyId, updatedArgs);
    this.channel.emit(Events.STORY_ARGS_UPDATED, {
      storyId,
      args: this.storyStore.args.get(storyId),
    });
  }

  async onResetArgs({ storyId, argNames }: { storyId: string; argNames?: string[] }) {
    // NOTE: we have to be careful here and avoid await-ing when updating the current story's args.
    // That's because below in `renderStoryToElement` we have also bound to this event and will
    // render the story in the same tick.
    // However, we can do that safely as the current story is available in `this.previousStory`
    const { initialArgs } =
      storyId === this.previousStory.id
        ? this.previousStory
        : await this.storyStore.loadStory({ storyId });

    const argNamesToReset = argNames || Object.keys(this.storyStore.args.get(storyId));
    const updatedArgs = argNamesToReset.reduce((acc, argName) => {
      acc[argName] = initialArgs[argName];
      return acc;
    }, {} as Partial<Args>);

    this.onUpdateArgs({ storyId, updatedArgs });
  }

  // RENDERING

  // We can either have:
  // - a story selected in "story" viewMode,
  //     in which case we render it to the root element, OR
  // - a story selected in "docs" viewMode,
  //     in which case we render the docsPage for that story
  async renderSelection({ persistedArgs }: { persistedArgs?: Args } = {}) {
    const { selection } = this.urlStore;
    if (!selection) {
      throw new Error('Cannot render story as no selection was made');
    }

    const { storyId } = selection;

    const storyIdChanged = this.previousSelection?.storyId !== storyId;
    const viewModeChanged = this.previousSelection?.viewMode !== selection.viewMode;

    // Show a spinner while we load the next story
    if (selection.viewMode === 'story') {
      this.view.showPreparingStory();
    } else {
      this.view.showPreparingDocs();
    }

    let story;
    try {
      story = await this.storyStore.loadStory({ storyId });
    } catch (err) {
      await this.cleanupPreviousRender();
      this.previousStory = null;
      this.renderStoryLoadingException(storyId, err);
      return;
    }

    const implementationChanged =
      !storyIdChanged && this.previousStory && story !== this.previousStory;

    if (persistedArgs) {
      this.storyStore.args.updateFromPersisted(story, persistedArgs);
    }

    // Don't re-render the story if nothing has changed to justify it
    if (this.previousStory && !storyIdChanged && !implementationChanged && !viewModeChanged) {
      this.channel.emit(Events.STORY_UNCHANGED, storyId);
      this.view.showMain();
      return;
    }

    await this.cleanupPreviousRender({ unmountDocs: viewModeChanged });

    // If we are rendering something new (as opposed to re-rendering the same or first story), emit
    if (this.previousSelection && (storyIdChanged || viewModeChanged)) {
      this.channel.emit(Events.STORY_CHANGED, storyId);
    }

    // Record the previous selection *before* awaiting the rendering, in cases things change before it is done.
    this.previousSelection = selection;
    this.previousStory = story;

    const { parameters, initialArgs, argTypes, args } = this.storyStore.getStoryContext(story);
    if (global.FEATURES?.storyStoreV7) {
      this.channel.emit(Events.STORY_PREPARED, {
        id: storyId,
        parameters,
        initialArgs,
        argTypes,
        args,
      });
    }

    // For v6 mode / compatibility
    // If the implementation changed, or args were persisted, the args may have changed,
    // and the STORY_PREPARED event above may not be respected.
    if (implementationChanged || persistedArgs) {
      this.channel.emit(Events.STORY_ARGS_UPDATED, { storyId, args });
    }

    if (selection.viewMode === 'docs' || story.parameters.docsOnly) {
      this.previousCleanup = await this.renderDocs({ story });
    } else {
      this.previousCleanup = this.renderStory({ story });
    }
  }

  async renderDocs({ story }: { story: Story<TFramework> }) {
    const { id, title, name } = story;
    const csfFile: CSFFile<TFramework> = await this.storyStore.loadCSFFileByStoryId(id);
    const docsContext = {
      id,
      title,
      name,
      // NOTE: these two functions are *sync* so cannot access stories from other CSF files
      storyById: (storyId: StoryId) => this.storyStore.storyFromCSFFile({ storyId, csfFile }),
      componentStories: () => this.storyStore.componentStoriesFromCSFFile({ csfFile }),
      loadStory: (storyId: StoryId) => this.storyStore.loadStory({ storyId }),
      renderStoryToElement: this.renderStoryToElement.bind(this),
      getStoryContext: (renderedStory: Story<TFramework>) =>
        ({
          ...this.storyStore.getStoryContext(renderedStory),
          viewMode: 'docs' as ViewMode,
        } as StoryContextForLoaders<TFramework>),
    };

    const render = async () => {
      const fullDocsContext = {
        ...docsContext,
        // Put all the storyContext fields onto the docs context for back-compat
        ...(!global.FEATURES?.breakingChangesV7 && this.storyStore.getStoryContext(story)),
      };

      const renderer = await import('./renderDocs');
      const element = this.view.prepareForDocs();
      renderer.renderDocs(story, fullDocsContext, element, () =>
        this.channel.emit(Events.DOCS_RENDERED, id)
      );
    };

    // Initially render right away
    render();

    // Listen to events and re-render
    // NOTE: we aren't checking to see the story args are targetted at the "right" story.
    // This is because we may render >1 story on the page and there is no easy way to keep track
    // of which ones were rendered by the docs page.
    // However, in `modernInlineRender`, the individual stories track their own events as they
    // each call `renderStoryToElement` below.
    if (!global.FEATURES?.modernInlineRender) {
      this.channel.on(Events.UPDATE_GLOBALS, render);
      this.channel.on(Events.UPDATE_STORY_ARGS, render);
      this.channel.on(Events.RESET_STORY_ARGS, render);
    }

    return async () => {
      if (!global.FEATURES?.modernInlineRender) {
        this.channel.off(Events.UPDATE_GLOBALS, render);
        this.channel.off(Events.UPDATE_STORY_ARGS, render);
        this.channel.off(Events.RESET_STORY_ARGS, render);
      }
    };
  }

  renderStory({ story }: { story: Story<TFramework> }) {
    const element = this.view.prepareForStory(story);
    const { id, componentId, title, name } = story;
    const renderContext = {
      componentId,
      title,
      kind: title,
      id,
      name,
      story: name,
      showMain: () => this.view.showMain(),
      showError: (err: { title: string; description: string }) => this.renderError(id, err),
      showException: (err: Error) => this.renderException(id, err),
    };

    return this.renderStoryToElement({ story, renderContext, element, viewMode: 'story' });
  }

  // Render a story into a given element and watch for the events that would trigger us
  // to re-render it (plus deal sensibly with things like changing story mid-way through).
  renderStoryToElement({
    story,
    renderContext: renderContextWithoutStoryContext,
    element: canvasElement,
    viewMode,
  }: {
    story: Story<TFramework>;
    renderContext: Omit<
      RenderContext<TFramework>,
      'storyContext' | 'storyFn' | 'unboundStoryFn' | 'forceRemount'
    >;
    element: HTMLElement;
    viewMode: ViewMode;
  }): StoryCleanupFn {
    const { id, applyLoaders, unboundStoryFn, playFunction } = story;

    let notYetRendered = true;
    let phase: RenderPhase;
    const isPending = () => ['rendering', 'playing'].includes(phase);

    this.abortController = createController();

    const render = async ({ initial = false, forceRemount = false } = {}) => {
      if (forceRemount && !initial) {
        this.abortController.abort();
        this.abortController = createController();
      }

      const abortSignal = this.abortController.signal; // we need a stable reference to the signal
      const runPhase = async (phaseName: RenderPhase, phaseFn?: () => MaybePromise<void>) => {
        phase = phaseName;
        this.channel.emit(Events.STORY_RENDER_PHASE_CHANGED, { newPhase: phase, storyId: id });
        if (phaseFn) await phaseFn();
        if (abortSignal.aborted) {
          phase = 'aborted';
          this.channel.emit(Events.STORY_RENDER_PHASE_CHANGED, { newPhase: phase, storyId: id });
        }
      };

      try {
        let loadedContext: StoryContext<TFramework>;
        await runPhase('loading', async () => {
          loadedContext = await applyLoaders({
            ...this.storyStore.getStoryContext(story),
            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.storyStore.getStoryContext(story),
          abortSignal,
          canvasElement,
        };
        const renderContext: RenderContext<TFramework> = {
          ...renderContextWithoutStoryContext,
          forceRemount: forceRemount || notYetRendered,
          storyContext: renderStoryContext,
          storyFn: () => unboundStoryFn(renderStoryContext),
          unboundStoryFn,
        };

        await runPhase('rendering', () => this.renderToDOM(renderContext, canvasElement));
        notYetRendered = false;
        if (abortSignal.aborted) return;

        if (forceRemount && playFunction) {
          this.disableKeyListeners = true;
          await runPhase('playing', () => playFunction(renderContext.storyContext));
          await runPhase('played');
          this.disableKeyListeners = false;
          if (abortSignal.aborted) return;
        }

        await runPhase('completed', () => this.channel.emit(Events.STORY_RENDERED, id));
      } catch (err) {
        renderContextWithoutStoryContext.showException(err);
      }
    };

    // 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.
    render({ initial: true, forceRemount: true });

    const remountStoryIfMatches = ({ storyId }: { storyId: StoryId }) => {
      if (storyId === story.id) render({ forceRemount: true });
    };
    const rerenderStoryIfMatches = ({ storyId }: { storyId: StoryId }) => {
      if (storyId === story.id) render();
    };

    // Listen to events and re-render story
    // Don't forget to unsubscribe on cleanup
    this.channel.on(Events.UPDATE_GLOBALS, render);
    this.channel.on(Events.FORCE_RE_RENDER, render);
    this.channel.on(Events.FORCE_REMOUNT, remountStoryIfMatches);
    this.channel.on(Events.UPDATE_STORY_ARGS, rerenderStoryIfMatches);
    this.channel.on(Events.RESET_STORY_ARGS, rerenderStoryIfMatches);

    // Cleanup / teardown function invoked on next render (via `cleanupPreviousRender`)
    return async () => {
      // 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.
      this.abortController.abort();

      this.storyStore.cleanupStory(story);
      this.channel.off(Events.UPDATE_GLOBALS, render);
      this.channel.off(Events.FORCE_RE_RENDER, render);
      this.channel.off(Events.FORCE_REMOUNT, remountStoryIfMatches);
      this.channel.off(Events.UPDATE_STORY_ARGS, rerenderStoryIfMatches);
      this.channel.off(Events.RESET_STORY_ARGS, rerenderStoryIfMatches);

      // Check if we're done rendering/playing. If not, we may have to reload the page.
      if (!isPending()) return;

      // 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".
      await new Promise((resolve) => setTimeout(resolve, 0));
      if (!isPending()) return;

      await new Promise((resolve) => setTimeout(resolve, 0));
      if (!isPending()) return;

      await new Promise((resolve) => setTimeout(resolve, 0));
      if (!isPending()) return;

      // 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(() => {});
    };
  }

  // API
  async extract(options?: { includeDocsOnly: boolean }) {
    if (this.previewEntryError) {
      throw this.previewEntryError;
    }

    if (!this.storyStore.projectAnnotations) {
      // In v6 mode, if your preview.js throws, we never get a chance to initialize the preview
      // or store, and the error is simply logged to the browser console. This is the best we can do
      throw new Error(dedent`Failed to initialize Storybook.
      
      Do you have an error in your \`preview.js\`? Check your Storybook's browser console for errors.`);
    }

    if (global.FEATURES?.storyStoreV7) {
      await this.storyStore.cacheAllCSFFiles();
    }

    return this.storyStore.extract(options);
  }

  // UTILITIES
  async cleanupPreviousRender({ unmountDocs = true }: { unmountDocs?: boolean } = {}) {
    const previousViewMode = this.previousStory?.parameters?.docsOnly
      ? 'docs'
      : this.previousSelection?.viewMode;

    if (unmountDocs && previousViewMode === 'docs') {
      (await import('./renderDocs')).unmountDocs(this.view.docsRoot());
    }

    if (this.previousCleanup) {
      await this.previousCleanup();
    }
  }

  renderPreviewEntryError(reason: string, err: Error) {
    this.previewEntryError = err;
    logger.error(reason);
    logger.error(err);
    this.view.showErrorDisplay(err);
    this.channel.emit(Events.CONFIG_ERROR, err);
  }

  renderMissingStory() {
    this.view.showNoPreview();
    this.channel.emit(Events.STORY_MISSING);
  }

  renderStoryLoadingException(storySpecifier: StorySpecifier, err: Error) {
    logger.error(`Unable to load story '${storySpecifier}':`);
    logger.error(err);
    this.view.showErrorDisplay(err);
    this.channel.emit(Events.STORY_MISSING, storySpecifier);
  }

  // renderException is used if we fail to render the story and it is uncaught by the app layer
  renderException(storyId: StoryId, err: Error) {
    this.channel.emit(Events.STORY_THREW_EXCEPTION, err);
    this.channel.emit(Events.STORY_RENDER_PHASE_CHANGED, { newPhase: 'errored', storyId });

    // Ignored exceptions exist for control flow purposes, and are typically handled elsewhere.
    if (err !== IGNORED_EXCEPTION) {
      this.view.showErrorDisplay(err);
      logger.error(`Error rendering story '${storyId}':`);
      logger.error(err);
    }
  }

  // renderError 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
  renderError(storyId: StoryId, { title, description }: { title: string; description: string }) {
    logger.error(`Error rendering story ${title}: ${description}`);
    this.channel.emit(Events.STORY_ERRORED, { title, description });
    this.channel.emit(Events.STORY_RENDER_PHASE_CHANGED, { newPhase: 'errored', storyId });
    this.view.showErrorDisplay({
      message: title,
      stack: description,
    });
  }
}