Skip to main content

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.