> ## Documentation Index
> Fetch the complete documentation index at: https://songbird.studiocollective.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Loading sequence

# Loading Sequence — Behavioral Spec

> App startup is the most fragile part of Songbird. Multiple async operations (store hydration, plugin scanning, bird parsing, engine initialization) must coordinate through guard flags. Race conditions here cause blank screens, missing tracks, or corrupted initial state.

## Invariants

* `uiReady()` must be called by React BEFORE any project loading begins. The Rust side cannot push state to a WebView that hasn't initialized.
* `reactReady()` fires ONLY when ALL persisted Zustand stores have completed hydration. If you add a new persisted store, it must participate in the hydration counter.
* The `store_hydrated` flag gates ALL state commits. No data is persisted to disk until hydration completes. This prevents the empty default state from overwriting saved files.
* The first commit after loading is ALWAYS `[auto] Project loaded`. This is the undo floor — the user cannot undo past it.
* Plugin state commits are DEFERRED until plugins settle. The "settle" delay accounts for plugins that report their state asynchronously.
* The loading sequence is identical across Tauri, WebSocket headless, and CLI modes — only the transport layer differs, not the orchestration logic.
* **Project picker visibility is owned by `useAppLifecycle` alone.** It shows the picker iff `app.ui_ready` returns `"no_project"`. There is no second code path that fires `notifications:show_project_picker` on startup blind to backend state — the previous version had one in `tauri-bridge.js` and it raced any "a project IS now loaded" signal arriving in the same window. New startup paths (scenario auto-runner, future `--project foo.bird` flag, Finder file-open associations) must work by influencing what `app.ui_ready` returns, not by emitting picker-related events independently.
* **The `app.ui_ready` handler returns `"ok"` if any of: a project is loaded, OR a scenario is pending creation (set by the scenario auto-runner before Tauri starts).** "Pending" is the second condition because if a scenario is about to create a project, the picker would briefly flash before being dismissed by the scenario's `project.new` event — visible jank for what's already a known-to-be-loading state.
* **`engineReady` (the gate that dismisses the LoadingScreen) is flipped true ONLY by a `notifications:loading_progress { message: "done" }` event.** Every load path must terminate in exactly one `done`. During a real load the rebuild worker (`rebuild.rs`) emits it. But on a **WebView reload** the Rust process keeps the loaded project and built session while React remounts fresh — no rebuild runs, so nothing would emit `done` and the LoadingScreen would stick forever. To cover this, `app.ui_ready` self-emits the terminal `done` when a project is loaded **and** `!is_rebuilding()` **and** not an Interface peer. The `!is_rebuilding()` guard is load-bearing: during a real load the rebuild owns the terminal `done`, and a second `done` from `ui_ready` would race ahead of the in-flight progress ticks (snap to end, then bounce back). Interface peers are excluded because their `done` arrives over the relay via `broadcast_all_global`.

## User Flows

### Normal Startup (Tauri Desktop)

1. Tauri launches → creates Rust backend → mounts WebView
2. React app mounts → calls `uiReady()` via IPC
3. Rust starts background loading:
   a. Scan for stock plugins (instant — they're compiled in)
   b. (If cached) load external plugin list from last scan
   c. Parse `.bird` file: tokenizer → parser → populator → `EngineSession`
   d. Instantiate plugins referenced in `daw.plugins.json`
   e. Build mixer state from `daw.mixer.json`
   f. Apply mixer state to audio engine graph
4. Rust pushes track state to React via events
5. Rust saves state cache → defers commit until plugins settle
6. Meanwhile (parallel): React hydrates Zustand stores via `getItem()` IPC → cached JSON returns
7. Each store's `onRehydrateStorage` fires → increments counter
8. All stores hydrated → `reactReady()` → sets `store_hydrated` flag
9. Plugins finish settling
10. Rust commits `[auto] Project loaded` → normal commits are now enabled
11. Loading screen dismisses → workspace is ready

### Headless Startup (WebSocket)

1. `songbird-headless` binary starts → binds WebSocket port
2. WebSocket client connects (React UI in browser or automation script)
3. Client sends `uiReady` message via WebSocket text frame
4. Same steps 3-11 as Tauri, but over WebSocket transport instead of Tauri IPC

### CLI Startup

1. `songbird-cli render project.bird` launches
2. Direct Rust API — no `uiReady()`, no hydration (no React at all)
3. Parse `.bird`, instantiate plugins, build engine
4. No UI stores to hydrate → skip hydration gate
5. Render audio offline → write to disk → exit

### WASM Startup

1. WASM module loads in browser
2. React mounts, calls `uiReady()` via WASM bindings
3. WASM engine emits `loadingProgress` events → React polls for 'done'
4. Race condition risk: React listener may not be registered when 'done' fires → use polling, not one-shot listener
5. Once 'done' received → loading screen dismisses

## Edge Cases

### Store Added Without Hydration Counter

* **Symptom:** App loads but commits never work. All state changes are silently dropped.
* **Cause:** New persisted store doesn't increment hydration counter → `store_hydrated` never sets to true → all commits gated forever.
* **Fix:** Every new persisted Zustand store MUST call the hydration counter increment in its `onRehydrateStorage` callback.

### Plugin Scanning Blocks Load

* **Symptom:** App takes 20+ seconds to show workspace.
* **Cause:** External plugin scan happening during load instead of on-demand.
* **Expected:** Stock plugins load instantly. External plugins are only scanned when the user opens the plugin browser or when a project references an unscanned UID.

### WASM loadingProgress Race

* **Symptom:** Loading screen never dismisses in WASM mode.
* **Cause:** React registers `loadingProgress` listener AFTER the WASM engine already emitted `done`.
* **Fix:** Use a polling mechanism that checks a flag/event queue, not a one-shot event listener.

### LoadingScreen Stuck After WebView Reload (cmd-R)

* **Symptom:** A project is open; the user reloads the WebView (cmd-R) and the app sticks on the LoadingScreen forever.
* **Cause:** Reload remounts React with `engineReady=false` but leaves the Rust process untouched — the project stays loaded and the session built. `app.ui_ready` sees `project_path.is_some()`, returns `"ok"`, and React dismisses the picker — but no rebuild runs, so nothing emits the `loading_progress { done }` that flips `engineReady` true. The LoadingScreen never unmounts.
* **Fix:** `app.ui_ready` self-emits the terminal `done` when a project is loaded and `!is_rebuilding()` (and not an Interface peer). See the `engineReady` invariant above for why the rebuilding guard is required.

### Project Picker Stuck Visible After Scenario / Auto-Load

* **Symptom:** App boots, briefly shows project picker, scenario's `project.new` lands but the picker doesn't dismiss.
* **Cause:** A second startup code path is emitting `notifications:show_project_picker` blind to backend state, racing the "now there's a project" signal. The legacy `tauri-bridge.js` emit was the historical culprit. If a new such path is added, it'll re-introduce this exact bug.
* **Fix:** Picker visibility goes through `useAppLifecycle.ts`'s `app.ui_ready` response only. If a code path needs to suppress the picker on startup (because it'll soon load / create a project), it should signal `app.ui_ready` to return `"ok"` — e.g. via the `SCENARIO_PENDING` atomic in `songbird_sync::dispatch`.

## Decisions

* **Why `uiReady()` before loading:** Pushing track state to a WebView that hasn't mounted React causes the events to be lost. The UI must signal readiness first.
* **Why a single picker-visibility owner:** The previous arrangement had `tauri-bridge.js` unconditionally emit `show_project_picker` \~500ms after page load, parallel to `useAppLifecycle` checking `app.ui_ready`. The two raced for scenarios where state changed in the same window (a scenario creating a project, a `--project` flag loading at boot). Symptom was a "picker briefly hides then comes back" flash. Collapsing to one owner — the `app.ui_ready` response — makes the question "should the picker show?" answerable from one place.
* **Why defer commits until plugin settle:** Some plugins (particularly VST3s with async initialization) report their state after a delay. Committing during this window captures incomplete plugin state, which would corrupt undo history.
* **Why the hydration counter pattern:** Multiple independent Zustand stores hydrate asynchronously. A simple boolean flag would require knowing which store hydrates last. The counter pattern is order-independent and extensible.
