Read OSS

The Preview Rendering Pipeline: From CSF Files to Rendered Stories

Advanced

Prerequisites

  • Article 1: Architecture Overview
  • Article 3: Channels and Events
  • Understanding of Component Story Format (CSF)
  • Basic React knowledge for renderer examples

The Preview Rendering Pipeline: From CSF Files to Rendered Stories

The Preview iframe is where the magic happens — where your components actually render. In the previous articles, we've traced how configuration is composed (Part 2) and how events flow between environments (Part 3). Now we follow the pipeline inside the iframe itself: from the generated entry script that bootstraps the entire runtime, through story loading and config composition, to the detailed render lifecycle that manages phases from preparation through play functions to completion.

This is the deepest technical article in the series. By the end, you'll understand exactly what happens between "the user clicks a story" and "pixels appear on screen."

Preview Bootstrap via Generated Code

The Preview iframe doesn't load a static JavaScript file. Instead, the builder (Vite or webpack) generates a virtual entry module that wires everything together:

code/builders/builder-vite/src/codegen-modern-iframe-script.ts#L53-L71

The generated code follows a precise sequence:

flowchart TD
    A["1. import { setup } from 'storybook/internal/preview/runtime'"] --> B["2. import addon setup (virtual)"]
    B --> C["3. setup() — apply globals, error handlers"]
    C --> D["4. import PreviewWeb from 'storybook/preview-api'"]
    D --> E["5. import { importFn } from virtual stories file"]
    E --> F["6. import { getProjectAnnotations } from virtual annotations"]
    F --> G["7. new PreviewWeb(importFn, getProjectAnnotations)"]
    G --> H["8. HMR handler setup"]

The setup() call runs the preview runtime we saw in Part 1 — it applies global packages (making storybook/preview-api exports available on window), sets up telemetry error handlers, and synchronizes the iframe's inert state with the Manager.

code/core/src/preview/runtime.ts#L23-L56

The two critical virtual modules are:

  • importFn — a function that lazily imports story files by path, generated from your stories glob patterns
  • getProjectAnnotations — assembles all preview annotations (decorators, parameters, etc.) from addons and your preview.ts

The Preview Class Hierarchy

The Preview operates through a two-class hierarchy:

code/core/src/preview-api/modules/preview-web/Preview.tsx#L60-L88

classDiagram
    class Preview~TRenderer~ {
        #storyStoreValue: StoryStore
        +renderToCanvas: RenderToCanvas
        +storyRenders: StoryRender[]
        #importFn: ModuleImportFn
        +getProjectAnnotations()
        +initialize()
        #setupListeners()
        +onStoriesChanged()
    }
    class PreviewWithSelection~TRenderer~ {
        +currentSelection: Selection
        +currentRender: PossibleRender
        +selectionStore: SelectionStore
        +view: View
        +setupListeners()
        -onSetCurrentStory()
        -renderSelection()
    }
    Preview <|-- PreviewWithSelection

Preview (the base class) handles:

  • Story loading and StoryStore initialization
  • Channel event listeners for args updates, globals changes, force re-render
  • The story index fetch and refresh cycle
  • Project annotation management

PreviewWithSelection (the subclass used in the browser) adds:

  • Story selection via SET_CURRENT_STORY events
  • URL synchronization
  • Render lifecycle orchestration (creating and managing StoryRender instances)
  • Keyboard event handling

The separation exists because Preview can be used standalone for testing scenarios (like the portable stories API) where there's no browser selection model.

StoryStore: Loading and Preparing Stories

The StoryStore is the central cache for all story data:

code/core/src/preview-api/modules/store/StoryStore.ts#L51-L97

flowchart TD
    Import["importFn('./Button.stories.tsx')"] --> Process["processCSFFile(exports, importPath)"]
    Process --> CSF["CSFFile { meta, stories[] }"]
    CSF --> Prepare["prepareStory(story, meta, projectAnnotations)"]
    Prepare --> PS["PreparedStory { 
      parameters, args, argTypes,
      decorators, loaders, playFunction,
      applyBeforeEach, applyAfterEach
    }"]
    PS --> Cache["Memoized cache (10,000 stories)"]

When a story is requested, the store:

  1. Imports the CSF module via importFn (the virtual module generated at build time)
  2. Processes the module with processCSFFile, extracting the meta (default export) and each named story export
  3. Prepares each story with prepareStory, which merges project-level, component-level, and story-level configuration

Both processCSFFile and prepareStory are memoized — processCSFFile with a cache of 1,000 entries, prepareStory with 10,000. This is important because stories are re-prepared on every args change or globals update; without memoization, the cost would be prohibitive.

composeConfigs: Merging Decorators, Parameters, and Args

The composition algorithm that merges configuration from multiple layers is implemented in composeConfigs:

code/core/src/preview-api/modules/store/csf/composeConfigs.ts#L43-L76

This function takes an array of module exports (from addons, your preview.ts, core annotations) and produces a single NormalizedProjectAnnotations object. Each field uses a specific merge strategy:

Field Strategy Notes
parameters Deep merge (combineParameters) Later values override earlier ones
decorators Array concatenation Reverse file order by default (outermost first)
args Object merge Later values override keys
argTypes Object merge Later values override keys
loaders Array concatenation Run in order
beforeEach / afterEach Array concatenation Lifecycle hooks
render Last wins (singleton) Only one render function
renderToCanvas Last wins (singleton) Framework-provided
tags Array concatenation Additive

The decorator ordering deserves special attention: by default (legacyDecoratorFileOrder: false), decorators are reversed so that the first decorator in your file wraps outermost. This matches the intuitive reading order where decorators: [withTheme, withRouter] means "withTheme wraps withRouter wraps the story."

Tip: If your decorators seem to be running in the wrong order, check whether features.legacyDecoratorFileOrder is set in your config. The default changed in Storybook 7, and some projects still have the legacy flag enabled.

The StoryRender Lifecycle

This is the heart of the rendering pipeline. When a story needs to be rendered, PreviewWithSelection creates a StoryRender instance that manages a multi-phase lifecycle:

code/core/src/preview-api/modules/preview-web/render/StoryRender.ts#L36-L48

stateDiagram-v2
    [*] --> preparing
    preparing --> loading: Story loaded from store
    loading --> beforeEach: Loaders resolved
    beforeEach --> rendering: beforeEach callbacks run
    rendering --> playing: Component mounted (if play function)
    rendering --> completing: Component mounted (no play function)
    playing --> played: Play function completed
    played --> completing: No errors
    playing --> errored: Play function threw
    completing --> completed: Animations settled
    completed --> afterEach: STORY_RENDERED emitted
    afterEach --> finished: Cleanup hooks run

    preparing --> aborted: AbortSignal
    loading --> aborted: AbortSignal
    playing --> aborted: AbortSignal

Each phase transition emits a STORY_RENDER_PHASE_CHANGED event, allowing the Manager (and addon panels like Interactions) to track progress. The phases are:

code/core/src/preview-api/modules/preview-web/render/StoryRender.ts#L105-L116

The runPhase helper method (line 105) updates the phase, emits the phase change event, runs the phase function, and checks for abort. Every phase checks the AbortSignal — if a new story is selected while the current one is still rendering, the old render is aborted cleanly.

Preparing (line 130–139): Loads the story from the StoryStore. If aborted during preparation, the story's cleanup is called immediately.

Loading (line 287–289): Runs the story's loaders — async functions that fetch data the story needs. The loaded data is attached to context.loaded.

BeforeEach (line 295–296): Runs beforeEach callbacks and collects their cleanup functions for later teardown.

Rendering (line 302–304): Calls context.mount(), which delegates to the framework renderer's renderToCanvas. This is where React's createRoot().render() or Vue's createApp().mount() happens.

Playing (line 328–341): If the story has a play function and autoplay is enabled, it runs here. Unhandled errors during play are captured and reported via PLAY_FUNCTION_THREW_EXCEPTION.

Completing → Completed (line 381–391): Waits for CSS animations to settle (or pauses them in test environments), then emits STORY_RENDERED.

AfterEach (line 394–397): Runs afterEach callbacks for cleanup.

Finished (not shown above): Emits STORY_FINISHED with success/failure status.

Renderer Integration and HMR

Framework renderers implement the renderToCanvas function that the StoryRender lifecycle calls during the "rendering" phase. This is a singleton field in composeConfigs — the last renderer to be loaded wins. For React, this creates a React root and renders the story; for Vue, it creates a Vue app instance; for Svelte, it mounts a Svelte component.

The generated entry script also sets up Hot Module Replacement:

code/builders/builder-vite/src/codegen-modern-iframe-script.ts#L32-L42

sequenceDiagram
    participant Vite as Vite HMR
    participant Entry as Entry Script
    participant Preview as PreviewWeb
    participant Channel as Channel

    Vite->>Entry: hot.accept(VIRTUAL_STORIES_FILE)
    Entry->>Preview: onStoriesChanged({ importFn: newModule.importFn })
    Preview->>Preview: Re-index stories, re-render current
    Vite->>Entry: hot.on('vite:afterUpdate')
    Entry->>Channel: emit(STORY_HOT_UPDATED)

When a story file changes, Vite triggers the HMR handler. The entry script passes the new importFn to PreviewWeb.onStoriesChanged(), which re-indexes the affected stories and re-renders the current selection if needed. The STORY_HOT_UPDATED event notifies the Manager that stories may have changed.

Web Components are a special case — they're not compatible with HMR, so the generated code calls import.meta.hot.decline() to force a full page reload instead.

Tip: If your stories aren't hot-reloading correctly, check if the file matches the stories glob pattern in your virtual module. Files outside the glob won't be watched by the HMR handler.

What's Next

We've traced the complete path through the Preview iframe — from bootstrap to rendering. The other half of the browser experience is the Manager UI: the React application that provides the sidebar, toolbar, and addon panels. In the next article, we'll explore its modular state architecture with 14 composable modules, the addon registration system, and the ref system that enables Storybook composition.