Read OSS

Manager UI:モジュール式状態管理とAddon統合

上級

前提知識

  • 第1回:アーキテクチャ概要
  • 第3回:ChannelとEvents
  • ReactコンポーネントパターンのContext、クラスコンポーネント
  • 状態管理パターンの理解

Manager UI:モジュール式状態管理とAddon統合

Managerは、皆さんが実際に操作するStorybookアプリケーションそのものです。サイドバー、ツールバー、addonパネル、設定ページなど、複雑な状態を管理するフルReactアプリケーションで、ストーリーツリー、レイアウト設定、キーボードショートカット、アクティブなglobals、登録済みaddonの状態を一括して担います。このアーキテクチャの最大の特徴はモジュール式の状態システムです。14個の独立したモジュールがそれぞれ状態とAPIのスライスを提供し、TypeScriptのintersection typeによって単一の統合インターフェースへと合成されます。

第4回ではPreviewのレンダリングパイプラインを探りました。今回はPostMessage境界の反対側を見ていきましょう。

Manager Bootstrap

起動シーケンスは runtime.tsx から始まります。このファイルは第1回で確認した「グローバル化されたランタイム」エントリとしてコンパイルされます。

code/core/src/manager/runtime.tsx#L32-L89

sequenceDiagram
    participant HTML as index.html
    participant RT as runtime.tsx
    participant RP as ReactProvider
    participant Channel as Channel
    participant UI as renderStorybookUI

    HTML->>RT: Script loaded
    RT->>RT: Register toolbar addon
    RT->>RP: new ReactProvider()
    RP->>Channel: createBrowserChannel({ page: 'manager' })
    RP->>Channel: addons.setChannel(channel)
    RP->>Channel: emit(CHANNEL_CREATED)
    RT->>UI: renderStorybookUI(rootEl, provider)

ReactProvider クラス(32行目)はコンストラクタで3つの処理を行います:

  1. PostMessage + WebSocketトランスポートでブラウザチャンネルを作成する
  2. addonsシングルトンにチャンネルを登録する
  3. CHANNEL_CREATED をemitして準備完了を通知する

また、CHANNEL_WS_DISCONNECT ハンドラも設定しており、開発サーバーへのWebSocket接続が切断されたとき(サーバーのクラッシュやタイムアウトでよく発生します)に通知を表示します。

実際のレンダリングは setTimeout(() => renderStorybookUI(...), 0)(86行目)で遅延されます。HTMLのグローバルscriptタグ(CHANNEL_OPTIONSCONFIG_TYPE などを設定するもの)がReactから使われる前に確実に評価済みであることを保証するためです。

コンポーネントツリー

ManagerのReactツリーは明確な階層構造を持っています。

code/core/src/manager/index.tsx#L32-L99

flowchart TD
    Root["Root"] --> Helmet["HelmetProvider"]
    Helmet --> Location["LocationProvider"]
    Location --> Main["Main"]
    Main --> LocationConsumer["Location (consumer)"]
    LocationConsumer --> MP["ManagerProvider"]
    MP --> Theme["ThemeProvider"]
    Theme --> LP["LayoutProvider"]
    LP --> App["App"]
    App --> Layout["Layout"]
    Layout --> Sidebar
    Layout --> Preview["Preview iframe"]
    Layout --> Panel["Addon Panel"]

各ラッパーにはそれぞれ固有の役割があります:

  • HelmetProvider:ドキュメントの <head> 要素(タイトル、メタタグ)を管理する
  • LocationProvider:URLルーティングと履歴管理
  • ManagerProvider:状態管理の中核(14モジュール、統合状態)
  • ThemeProviderstorybook/theming によるEmotionベースのテーマ管理
  • LayoutProvider:レイアウトの計測とリサイズ状態の管理

renderStorybookUI 関数(92行目)は、providerが基底の Provider クラスを継承しているかを検証し、React rootを作成してツリーをマウントします。この検証により、サードパーティコードが誤ったproviderオブジェクトを渡した際に起きやすいエラーを未然に防いでいます。

ManagerProviderとモジュールシステム

Managerの状態管理の中枢は ManagerProvider です。14個のモジュールを初期化してそれぞれの状態とAPIを合成する、Reactクラスコンポーネントです。

code/core/src/manager-api/root.tsx#L82-L110

StateとAPI型はTypeScriptのintersection typeで合成されています:

export type State = layout.SubState &
  stories.SubState &
  refs.SubState &
  notifications.SubState &
  version.SubState &
  url.SubState &
  shortcuts.SubState &
  settings.SubState &
  globals.SubState &
  whatsnew.SubState &
  RouterData &
  API_OptionsData &
  Other;

export type API = addons.SubAPI &
  channel.SubAPI &
  provider.SubAPI &
  stories.SubAPI &
  refs.SubAPI &
  globals.SubAPI &
  layout.SubAPI &
  notifications.SubAPI &
  shortcuts.SubAPI &
  settings.SubAPI &
  version.SubAPI &
  url.SubAPI &
  whatsnew.SubAPI &
  openInEditor.SubAPI &
  Other;
classDiagram
    class ManagerProvider {
        +api: API
        +modules: ModuleFn[]
        +state: State
        +initModules()
        +render()
    }
    class ModuleFn {
        <<interface>>
        +init(args): ModuleResult
    }
    class ModuleResult {
        +state: SubState
        +api: SubAPI
        +init(): void
    }

    ManagerProvider --> "14" ModuleFn : initializes
    ManagerProvider --> "1" State : composes
    ManagerProvider --> "1" API : composes

コンストラクタ(130〜197行目)では、各モジュールが共通の依存セットとともに初期化されます。

code/core/src/manager-api/root.tsx#L130-L197

this.modules = [
  provider, channel, addons, layout,
  notifications, settings, shortcuts, stories,
  refs, globals, url, version, whatsnew, openInEditor,
].map((m) =>
  m.init({ ...routeData, ...optionsData, ...apiData, state: this.state, fullAPI: this.api })
);

各モジュールの init 関数は完全なAPIリファレンス(最初は空のオブジェクトで、モジュールの初期化に伴って埋められていきます)を受け取ります。これにより、モジュール同士がお互いのAPIを呼び出せるようになっています。この循環参照は2フェーズで解消されます。まず全モジュールが状態スライスとAPIメソッドを返し、次に initModules() がReact effectとして呼び出されることで、他のモジュールの準備完了を前提とした初期化が実行されます。

ヒント: Managerの状態にアクセスするStorybookアドオンを作成するときは、storybook/manager-apiuseStorybookApi()useStorybookState() hookを活用しましょう。これらは ManagerContext を内部で消費しており、完全に合成されたAPIと状態を提供してくれます。

主要モジュールの詳細

Storiesモジュール

storiesモジュールは最も大規模で複雑なモジュールです。以下を管理します:

  • ストーリーツリー(IDからエントリへのマッピングである IndexHash
  • ストーリーの選択とナビゲーション
  • ストーリーのフィルタリング(タグ、ステータス、検索による絞り込み)
  • PreviewとのArgs状態の同期

code/core/src/manager-api/modules/stories.ts#L1-L80

SET_INDEX(ストーリーインデックスの変更時)、STORY_PREPARED(ストーリーの完全なメタデータが利用可能になったとき)、STORY_ARGS_UPDATED(PreviewでArgsが変更されたとき)といったチャンネルイベントを監視します。また、STORY_INDEX_INVALIDATED を受け取るとサーバーから index.json を再取得します。

Layoutモジュール

layoutモジュールはManagerのビジュアル構造を制御します。サイドバーの幅、パネルの位置(下または右)、パネルの表示/非表示、フルスクリーンモード、アクティブなaddonタブなどが対象です。状態は localStorage に保存されるため、ページをリロードしてもレイアウトの設定が引き継がれます。

Shortcutsモジュール

shortcutsモジュールはキーボードバインディング(サイドバーの切り替え、パネルの切り替え、次/前のストーリーへの移動など)を管理し、Managerフレームとプレビューiframe(PREVIEW_KEYDOWN 経由で転送されます)の両方からのキーダウンイベントを処理します。

Addonの登録とSlot

AddonはRegistration APIを通じてManagerに統合されます:

// Typical addon manager entry
import { addons, types } from 'storybook/manager-api';

addons.register('my-addon', (api) => {
  addons.add('my-addon/panel', {
    type: types.PANEL,
    title: 'My Panel',
    render: ({ active }) => active ? <MyPanel /> : null,
  });
});

esbuildベースのManagerビルダーは、すべてのaddon managerエントリをバンドルします。

code/core/src/builder-manager/index.ts#L36-L120

flowchart TD
    Presets["presets.apply('managerEntries')"] --> Entries["Collect all manager entries"]
    ConfigDir["configDir/manager.ts"] --> Entries
    Entries --> Wrap["wrapManagerEntries()"]
    Wrap --> ESBuild["esbuild bundle (IIFE format)"]
    ESBuild --> Output["sb-addons/*.js"]

    subgraph "Available Slot Types"
        PANEL["types.PANEL — Addon panels"]
        TOOL["types.TOOL — Toolbar tools"]
        TAB["types.TAB — Canvas tabs"]
        PAGE["types.experimental_PAGE — Full pages"]
    end

各エントリはtry/catchでラップされており(108〜111行目)、あるaddonが失敗してもManager全体がクラッシュしないようになっています。esbuildの設定では globalExternals を使い、addonがReact・storybook/manager-api・その他のグローバルをManagerと共有し、独自にバンドルしないようにしています。

利用可能なslotタイプは、addonがUIを挿入できる場所を定義します:

  • PANEL:previewの下または横のaddonパネルに表示される
  • TOOL:ツールバーに表示される
  • TAB:Canvasと並ぶタブとして表示される
  • experimental_PAGE:フルページのルート(Settingsで使用)

Refシステム:Storybookコンポジション

refsモジュールはStorybookコンポジションを実現します。複数のStorybookインスタンスのストーリーを1つのUIにまとめて表示する機能です。

code/core/src/manager-api/modules/refs.ts#L1-L60

flowchart TB
    Main["Main Storybook"] --> Sidebar
    Sidebar --> LocalStories["Local Stories (IndexHash)"]
    Sidebar --> Ref1["Ref: Design System Storybook"]
    Sidebar --> Ref2["Ref: Shared Components Storybook"]

    Ref1 -->|"fetch /index.json"| Remote1["Remote index.json"]
    Ref2 -->|"fetch /index.json"| Remote2["Remote index.json"]

    Ref1 -->|"iframe src"| Preview1["Remote Preview iframe"]
    Ref2 -->|"iframe src"| Preview2["Remote Preview iframe"]

main.ts にrefsを設定すると、Managerは各リモートStorybookの index.json を取得し、ストーリーインデックスを変換して、ローカルのストーリーと並べてサイドバーに統合します。ユーザーがリモートのストーリーを選択すると、Preview iframeはローカルサーバーではなくリモートのURLから読み込まれます。

refsの SubAPI インターフェースには findRefsetRefupdateRefgetRefscheckRef といったメソッドが含まれます。状態では各refの読み込み状況、ストーリー、バージョン情報を追跡しています。

ヒント: Storybookコンポジションはデザインシステムで特に威力を発揮します。デザインシステムのStorybookを静的サイトとして公開し、利用側アプリケーションのStorybookの設定でそれを参照するだけです。コンポーネントコードを複製することなく、すべてのストーリーを1つのサイドバーで確認できます。

次回予告

これで3つのランタイム環境をすべて詳しく解説しました。サーバー(第1〜2回)、Preview(第4回)、今回のManagerです。最終回では、これらすべてを支えるビルドインフラに焦点を当てます。Nxが管理するモノレポパイプライン、ストーリーインデックスジェネレーター、変更検出、サンドボックステスト、そして構造化されたエラーシステムを取り上げる予定です。