The Preview Rendering Pipeline: From CSF Files to Rendered Stories
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 yourstoriesglob patternsgetProjectAnnotations— assembles all preview annotations (decorators, parameters, etc.) from addons and yourpreview.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_STORYevents - URL synchronization
- Render lifecycle orchestration (creating and managing
StoryRenderinstances) - 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:
- Imports the CSF module via
importFn(the virtual module generated at build time) - Processes the module with
processCSFFile, extracting the meta (default export) and each named story export - 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.legacyDecoratorFileOrderis 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.