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_hydratedflag 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
useAppLifecyclealone. It shows the picker iffapp.ui_readyreturns"no_project". There is no second code path that firesnotifications:show_project_pickeron startup blind to backend state — the previous version had one intauri-bridge.jsand it raced any “a project IS now loaded” signal arriving in the same window. New startup paths (scenario auto-runner, future--project foo.birdflag, Finder file-open associations) must work by influencing whatapp.ui_readyreturns, not by emitting picker-related events independently. - The
app.ui_readyhandler 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’sproject.newevent — visible jank for what’s already a known-to-be-loading state. engineReady(the gate that dismisses the LoadingScreen) is flipped true ONLY by anotifications:loading_progress { message: "done" }event. Every load path must terminate in exactly onedone. 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 emitdoneand the LoadingScreen would stick forever. To cover this,app.ui_readyself-emits the terminaldonewhen 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 terminaldone, and a seconddonefromui_readywould race ahead of the in-flight progress ticks (snap to end, then bounce back). Interface peers are excluded because theirdonearrives over the relay viabroadcast_all_global.
User Flows
Normal Startup (Tauri Desktop)
- Tauri launches → creates Rust backend → mounts WebView
- React app mounts → calls
uiReady()via IPC - 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
.birdfile: tokenizer → parser → populator →EngineSessiond. Instantiate plugins referenced indaw.plugins.jsone. Build mixer state fromdaw.mixer.jsonf. Apply mixer state to audio engine graph - Rust pushes track state to React via events
- Rust saves state cache → defers commit until plugins settle
- Meanwhile (parallel): React hydrates Zustand stores via
getItem()IPC → cached JSON returns - Each store’s
onRehydrateStoragefires → increments counter - All stores hydrated →
reactReady()→ setsstore_hydratedflag - Plugins finish settling
- Rust commits
[auto] Project loaded→ normal commits are now enabled - Loading screen dismisses → workspace is ready
Headless Startup (WebSocket)
songbird-headlessbinary starts → binds WebSocket port- WebSocket client connects (React UI in browser or automation script)
- Client sends
uiReadymessage via WebSocket text frame - Same steps 3-11 as Tauri, but over WebSocket transport instead of Tauri IPC
CLI Startup
songbird-cli render project.birdlaunches- Direct Rust API — no
uiReady(), no hydration (no React at all) - Parse
.bird, instantiate plugins, build engine - No UI stores to hydrate → skip hydration gate
- Render audio offline → write to disk → exit
WASM Startup
- WASM module loads in browser
- React mounts, calls
uiReady()via WASM bindings - WASM engine emits
loadingProgressevents → React polls for ‘done’ - Race condition risk: React listener may not be registered when ‘done’ fires → use polling, not one-shot listener
- 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_hydratednever sets to true → all commits gated forever. - Fix: Every new persisted Zustand store MUST call the hydration counter increment in its
onRehydrateStoragecallback.
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
loadingProgresslistener AFTER the WASM engine already emitteddone. - 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=falsebut leaves the Rust process untouched — the project stays loaded and the session built.app.ui_readyseesproject_path.is_some(), returns"ok", and React dismisses the picker — but no rebuild runs, so nothing emits theloading_progress { done }that flipsengineReadytrue. The LoadingScreen never unmounts. - Fix:
app.ui_readyself-emits the terminaldonewhen a project is loaded and!is_rebuilding()(and not an Interface peer). See theengineReadyinvariant 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.newlands but the picker doesn’t dismiss. - Cause: A second startup code path is emitting
notifications:show_project_pickerblind to backend state, racing the “now there’s a project” signal. The legacytauri-bridge.jsemit 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’sapp.ui_readyresponse only. If a code path needs to suppress the picker on startup (because it’ll soon load / create a project), it should signalapp.ui_readyto return"ok"— e.g. via theSCENARIO_PENDINGatomic insongbird_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.jsunconditionally emitshow_project_picker~500ms after page load, parallel touseAppLifecyclecheckingapp.ui_ready. The two raced for scenarios where state changed in the same window (a scenario creating a project, a--projectflag loading at boot). Symptom was a “picker briefly hides then comes back” flash. Collapsing to one owner — theapp.ui_readyresponse — 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.